diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 54bb1b9..81dbfa9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: postgres: image: amazon/dynamodb-local:latest ports: - - 8000:8000 + - 18000:8000 steps: - name: Checkout uses: actions/checkout@v2 @@ -31,6 +31,6 @@ jobs: run: | set -xue go get ./... - go test ./... + go test -p 1 ./... env: GOPRIVATE: "github:com/lmika/*" \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..6a4cf66 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,52 @@ +name: release + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: amazon/dynamodb-local:latest + ports: + - 18000:8000 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Go + uses: actions/setup-go@v2 + with: + go-version: 1.18 + - name: Configure + run: | + git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika" + - name: Test + run: | + set -xue + go get ./... + go test -p 1 ./... + env: + GOPRIVATE: "github:com/lmika/*" + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Go + uses: actions/setup-go@v2 + with: + go-version: 1.18 + - name: Configure + run: | + git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika" + - name: Release + uses: goreleaser/goreleaser-action@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + version: latest + args: release --skip-validate --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..970b603 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,54 @@ +builds: + - id: dynamo-browse + targets: + - windows_amd64 + - linux_amd64 + - darwin_amd64 + - darwin_arm64 + main: ./cmd/dynamo-browse/. + binary: dynamo-browse +archives: + - id: zip + builds: + - dynamo-browse + wrap_in_directory: true + format_overrides: + - goos: windows + format: zip + - goos: linux + format: tar.gz + - goos: macos + format: tar.gz +nfpms: + - id: package_nfpms + package_name: awstools + builds: + - dynamo-browse + vendor: lmika + homepage: https://awstools.lmika.dev/ + maintainer: Leon Mika + description: TUI tools for AWS administration + license: MIT + formats: + - deb + - rpm + bindir: /usr/local/bin +brews: + - name: awstools + tap: + owner: lmika + name: awstools + folder: Formula + homepage: https://awstools.lmika.dev + description: TUI tools for AWS administration + license: MIT +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' \ No newline at end of file diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index a1ad78b..b84528f 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -10,18 +10,21 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/common/ui/commandctrl" "github.com/lmika/awstools/internal/common/ui/logging" + "github.com/lmika/awstools/internal/common/ui/osstyle" "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" "github.com/lmika/awstools/internal/dynamo-browse/ui" "github.com/lmika/gopkgs/cli" "log" + "net" "os" ) func main() { var flagTable = flag.String("t", "", "dynamodb table name") - var flagLocal = flag.Bool("local", false, "local endpoint") + var flagLocal = flag.String("local", "", "local endpoint") + var flagDebug = flag.String("debug", "", "file to log debug messages") flag.Parse() ctx := context.Background() @@ -32,9 +35,19 @@ func main() { } var dynamoClient *dynamodb.Client - if *flagLocal { + if *flagLocal != "" { + host, port, err := net.SplitHostPort(*flagLocal) + if err != nil { + cli.Fatalf("invalid address '%v': %v", *flagLocal, err) + } + if host == "" { + host = "localhost" + } + if port == "" { + port = "8000" + } dynamoClient = dynamodb.NewFromConfig(cfg, - dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:4566"))) + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL(fmt.Sprintf("http://%v:%v", host, port)))) } else { dynamoClient = dynamodb.NewFromConfig(cfg) } @@ -57,6 +70,23 @@ func main() { closeFn := logging.EnableLogging() defer closeFn() + // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. + if lipgloss.HasDarkBackground() { + if colorScheme := osstyle.CurrentColorScheme(); colorScheme == osstyle.ColorSchemeLightMode { + log.Printf("terminal reads dark but really in light mode") + lipgloss.SetHasDarkBackground(true) + } else { + log.Printf("in dark background") + } + } else { + if colorScheme := osstyle.CurrentColorScheme(); colorScheme == osstyle.ColorSchemeDarkMode { + log.Printf("terminal reads light but really in dark mode") + lipgloss.SetHasDarkBackground(true) + } else { + log.Printf("cannot detect system darkmode") + } + } + log.Println("launching") if err := p.Start(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) diff --git a/cmd/slog-view/main.go b/cmd/slog-view/main.go index f577a07..5b49b79 100644 --- a/cmd/slog-view/main.go +++ b/cmd/slog-view/main.go @@ -7,14 +7,15 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/common/ui/commandctrl" "github.com/lmika/awstools/internal/common/ui/logging" - "github.com/lmika/awstools/internal/slog-view/services/logreader" "github.com/lmika/awstools/internal/slog-view/controllers" + "github.com/lmika/awstools/internal/slog-view/services/logreader" "github.com/lmika/awstools/internal/slog-view/ui" "github.com/lmika/gopkgs/cli" "os" ) func main() { + var flagDebug = flag.String("debug", "", "file to log debug messages") flag.Parse() if flag.NArg() == 0 { @@ -24,7 +25,7 @@ func main() { // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. lipgloss.HasDarkBackground() - closeFn := logging.EnableLogging() + closeFn := logging.EnableLogging(*flagDebug) defer closeFn() service := logreader.NewService() diff --git a/cmd/ssm-browse/main.go b/cmd/ssm-browse/main.go index 5124e05..967a0d7 100644 --- a/cmd/ssm-browse/main.go +++ b/cmd/ssm-browse/main.go @@ -20,12 +20,13 @@ import ( func main() { var flagLocal = flag.Bool("local", false, "local endpoint") + var flagDebug = flag.String("debug", "", "file to log debug messages") flag.Parse() // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. lipgloss.HasDarkBackground() - closeFn := logging.EnableLogging() + closeFn := logging.EnableLogging(*flagDebug) defer closeFn() cfg, err := config.LoadDefaultConfig(context.Background()) diff --git a/go.mod b/go.mod index da7b341..6b98c88 100644 --- a/go.mod +++ b/go.mod @@ -5,16 +5,15 @@ go 1.18 require ( github.com/alecthomas/participle/v2 v2.0.0-alpha7 github.com/asdine/storm v2.1.2+incompatible - github.com/aws/aws-sdk-go-v2 v1.16.1 + github.com/aws/aws-sdk-go-v2 v1.16.5 github.com/aws/aws-sdk-go-v2/config v1.13.1 github.com/aws/aws-sdk-go-v2/credentials v1.8.0 - github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0 - github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0 + github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.9.4 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.7 github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 github.com/brianvoe/gofakeit/v6 v6.15.0 - github.com/calyptia/go-bubble-table v0.1.0 - github.com/charmbracelet/bubbles v0.10.3 - github.com/charmbracelet/bubbletea v0.20.0 + github.com/charmbracelet/bubbles v0.11.0 + github.com/charmbracelet/bubbletea v0.21.0 github.com/charmbracelet/lipgloss v0.5.0 github.com/google/uuid v1.3.0 github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e @@ -26,34 +25,37 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.10 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5 // indirect - github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.0 // indirect + github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.24.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 // indirect - github.com/aws/smithy-go v1.11.2 // indirect + github.com/aws/smithy-go v1.11.3 // indirect github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect + github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect - github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect + github.com/muesli/cancelreader v0.2.0 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect + github.com/muesli/termenv v0.12.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect go.etcd.io/bbolt v1.3.6 // indirect - golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect ) diff --git a/go.sum b/go.sum index 527818a..175f3fe 100644 --- a/go.sum +++ b/go.sum @@ -12,12 +12,18 @@ github.com/aws/aws-sdk-go-v2 v1.15.0 h1:f9kWLNfyCzCB43eupDAk3/XgJ2EpgktiySD6leqs github.com/aws/aws-sdk-go-v2 v1.15.0/go.mod h1:lJYcuZZEHWNIb6ugJjbQY1fykdoobWbOS7kJYb4APoI= github.com/aws/aws-sdk-go-v2 v1.16.1 h1:udzee98w8H6ikRgtFdVN9JzzYEbi/quFfSvduZETJIU= github.com/aws/aws-sdk-go-v2 v1.16.1/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= +github.com/aws/aws-sdk-go-v2 v1.16.5 h1:Ah9h1TZD9E2S1LzHpViBO3Jz9FPL5+rmflmb8hXirtI= +github.com/aws/aws-sdk-go-v2 v1.16.5/go.mod h1:Wh7MEsmEApyL5hrWzpDkba4gwAPc5/piwLVLFnCxp48= github.com/aws/aws-sdk-go-v2/config v1.13.1 h1:yLv8bfNoT4r+UvUKQKqRtdnvuWGMK5a82l4ru9Jvnuo= github.com/aws/aws-sdk-go-v2/config v1.13.1/go.mod h1:Ba5Z4yL/UGbjQUzsiaN378YobhFo0MLfueXGiOsYtEs= github.com/aws/aws-sdk-go-v2/credentials v1.8.0 h1:8Ow0WcyDesGNL0No11jcgb1JAtE+WtubqXjgxau+S0o= github.com/aws/aws-sdk-go-v2/credentials v1.8.0/go.mod h1:gnMo58Vwx3Mu7hj1wpcG8DI0s57c9o42UQ6wgTQT5to= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0 h1:XxTy21xVUkoCZOSGwf+AW22v8aK3eEbYMaGGQ3MbKKk= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0/go.mod h1:6WkjzWenkrj3IgLPIPBBz4Qh99jNDF8L4Wj03vfMhAA= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.9.4 h1:EoyeSOfbSuKh+bQIDoZaVJjON6PF+dsSn5w1RhIpMD0= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.9.4/go.mod h1:bfCL7OwZS6owS06pahfGxhcgpLWj2W1sQASoYRuenag= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.10 h1:IBIZfpnWCTTQhH/bMvDcCMw10BtLBPYO30Ev8MLXMTY= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.10/go.mod h1:RL7aJOwlWj2N6wkE4nKR1S5M4iGph+xSu7JovwNYpyU= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 h1:NITDuUZO34mqtOwFWZiXo7yAHj7kf+XPE+EiKuCBNUI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0/go.mod h1:I6/fHT/fH460v09eg2gVrd8B/IqskhNdpcLH0WNO3QI= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4 h1:CRiQJ4E2RhfDdqbie1ZYDo8QtIo75Mk7oTdJSfwJTMQ= @@ -26,22 +32,34 @@ github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6 h1:xiGjGVQsem2cxoIX61 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6/go.mod h1:SSPEdf9spsFgJyhjrXvawfpyzrXHBCUe+2eQ1CjC1Ak= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.8 h1:CDaO90VZVBAL1sK87S5oSPIrp7yZqORv1hPIi2UsTMk= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.8/go.mod h1:LnTQMTqbKsbtt+UI5+wPsB7jedW+2ZgozoPG8k6cMxg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12 h1:Zt7DDk5V7SyQULUUwIKzsROtVzp/kVvcz15uQx/Tkow= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12/go.mod h1:Afj/U8svX6sJ77Q+FPWMzabJ9QjbwP32YlopgKALUpg= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0 h1:3ADoioDMOtF4uiK59vCpplpCwugEU+v4ZFD29jDL3RQ= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0/go.mod h1:BsCSJHx5DnDXIrOcqB8KN1/B+hXLG/bi4Y6Vjcx/x9E= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 h1:bt3zw79tm209glISdMRCIVRCwvSDXxgAxh5KWe2qHkY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0/go.mod h1:viTrxhAuejD+LszDahzAE2x40YjYWhMqzHxv2ZiWaME= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.2 h1:XXR3cdOcKRCTZf6ctcqpMf+go1BdzTm6+T9Ul5zxcMI= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.2/go.mod h1:1x4ZP3Z8odssdhuLI+/1Tqw6Pt/VAaP4Tr8EUxHvPXE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6 h1:eeXdGVtXEe+2Jc49+/vAzna3FAQnUD4AagAw8tzbmfc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6/go.mod h1:FwpAKI+FBPIELJIdmQzlLtRe8LQSOreMcM2wBsPMvvc= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5 h1:ixotxbfTCFpqbuwFv/RcZwyzhkxPSYDYEMcj4niB5Uk= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5/go.mod h1:R3sWUqPcfXSiF/LSFJhjyJmpg9uV6yP2yv3YZZjldVI= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0 h1:qnx+WyIH9/AD+wAxi05WCMNanO236ceqHg6hChCWs3M= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0/go.mod h1:+Kc1UmbE37ijaAsb3KogW6FR8z0myjX6VtdcCkQEK0k= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.7 h1:Ls6kDGWNr3wxE8JypXgTTonHpQ1eRVCGNqaFHY2UASw= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.7/go.mod h1:+v2jeT4/39fCXUQ0ZfHQHMMiJljnmiuj16F03uAd9DY= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.0 h1:s71pGCiLqqGRoUWtdJ2j4PazwEpZVwQc16na/4FfXdk= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.0/go.mod h1:YGzTq/joAih4HRZZtMBWGP4bI8xVucOBQ9RvuanpclA= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.7 h1:o2HKntJx3vr3y11NK58RA6tYKZKQo5PWWt/bs0rWR0U= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.7/go.mod h1:FAVtDKEl/8WxRDQ33e2fz16RO1t4zeEwWIU5kR29xXs= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0 h1:uhb7moM7VjqIEpWzTpCvceLDSwrWpaleXm39OnVjuLE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0/go.mod h1:pA2St3Pu2Ldy6fBPY45Azoh1WBG4oS7eIKOd4XN7Meg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.2 h1:T/ywkX1ed+TsZVQccu/8rRJGxKZF/t0Ivgrb4MHTSeo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.2/go.mod h1:RnloUnyZ4KN9JStGY1LuQ7Wzqh7V0f8FinmRdHYtuaA= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.0 h1:6Bc0KHhAyxGe15JUHrK+Udw7KhE5LN+5HKZjQGo4yDI= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.0/go.mod h1:0nXuX9UrkN4r0PX9TSKfcueGRfsdEYIKG4rjTeJ61X8= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.6 h1:JGrc3+kkyr848/wpG2+kWuzHK3H4Fyxj2jnXj8ijQ/Y= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.6/go.mod h1:zwvTysbXES8GDwFcwCPB8NkC+bCdio1abH+E+BRe/xg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 h1:4QAOB3KrvI1ApJK14sliGr3Ie2pjyvNypn/lfzDHfUw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0/go.mod h1:K/qPe6AP2TGYv4l6n7c88zh9jWBDf6nHhvg1fx/EWfU= github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 h1:dzWS4r8E9bA0TesHM40FSAtedwpTVCuTsLI8EziSqyk= @@ -58,16 +76,25 @@ github.com/aws/smithy-go v1.11.1 h1:IQ+lPZVkSM3FRtyaDox41R8YS6iwPMYIreejOgPW49g= github.com/aws/smithy-go v1.11.1/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= github.com/aws/smithy-go v1.11.2 h1:eG/N+CcUMAvsdffgMvjMKwfyDzIkjM6pfxMJ8Mzc6mE= github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= +github.com/aws/smithy-go v1.11.3 h1:DQixirEFM9IaKxX1olZ3ke3nvxRS2xMDteKIDWxozW8= +github.com/aws/smithy-go v1.11.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/brianvoe/gofakeit/v6 v6.15.0 h1:lJPGJZ2/07TRGDazyTzD5b18N3y4tmmJpdhCUw18FlI= github.com/brianvoe/gofakeit/v6 v6.15.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= github.com/calyptia/go-bubble-table v0.1.0 h1:mXpaaBlrHGH4K8v5PvM8YqBFT9jlysS1YOycU2u3gEQ= github.com/calyptia/go-bubble-table v0.1.0/go.mod h1:2nnweuFos+eEIIbgweXvZuX+ROOatsMwB3NHnX/vTC4= +github.com/calyptia/go-bubble-table v0.2.1 h1:NWcVRyGCLuP7QIA29uUFSY+IjmWcmUWHjy5J/CPb0Rk= +github.com/calyptia/go-bubble-table v0.2.1/go.mod h1:gJvzUOUzfQeA9JmgLumyJYWJMtuRQ7WxxTwc9tjEiGw= github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= +github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q= +github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= +github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI= +github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= @@ -80,6 +107,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -93,6 +121,10 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e h1:0QkUe2ejnT/i+xbgGylMU1b+XnZponQKiPVNi+C/xgA= github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e/go.mod h1:qtkBmNC9OfD0STtOR9sF55pQchjIfNlC3gzm4n8CrqM= +github.com/lmika/go-bubble-table v0.2.2-0.20220608033210-61eeb29a6239 h1:GGw5pZtEFnHtD7kKdWsiwgcIwZTnok60sShrHVYz4ok= +github.com/lmika/go-bubble-table v0.2.2-0.20220608033210-61eeb29a6239/go.mod h1:0RT1upgKZ6qZ6B1SqseE3wWsPjSQRv/G/HjpYK8jNsg= +github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 h1:dtMPRNoDqDnnP3HgOvYhswcJVSqdISkYlCtGOjTqg6Q= +github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538/go.mod h1:0RT1upgKZ6qZ6B1SqseE3wWsPjSQRv/G/HjpYK8jNsg= github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 h1:mwl/exYV/WkBMeShqK7q+B2w2r+b0vP1TSA7clBn9kI= github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890/go.mod h1:FH6OJSvYcJ9xY8CGs9yGgR89kMCK1UimuUQ6kE5YuJQ= github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe h1:1UXS/6OFkbi6JrihPykmYO1VtsABB02QQ+YmYYzTY18= @@ -112,6 +144,10 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q= +github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= @@ -119,6 +155,8 @@ github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtl github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= +github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -140,11 +178,18 @@ golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/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-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 4332023..a71352a 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -2,6 +2,8 @@ package commandctrl import ( tea "github.com/charmbracelet/bubbletea" + "github.com/pkg/errors" + "log" "strings" "github.com/lmika/awstools/internal/common/ui/events" @@ -35,15 +37,18 @@ func (c *CommandController) Prompt() tea.Cmd { } func (c *CommandController) Execute(commandInput string) tea.Cmd { + log.Println("Received input: ", commandInput) input := strings.TrimSpace(commandInput) if input == "" { return nil } tokens := shellwords.Split(input) + log.Println("Tokens: ", tokens) command := c.lookupCommand(tokens[0]) if command == nil { - return events.SetStatus("no such command: " + tokens[0]) + log.Println("No such command: ", tokens) + return events.SetError(errors.New("no such command: " + tokens[0])) } return command(tokens[1:]) @@ -51,6 +56,7 @@ func (c *CommandController) Execute(commandInput string) tea.Cmd { func (c *CommandController) lookupCommand(name string) Command { for ctx := c.commandList; ctx != nil; ctx = ctx.parent { + log.Printf("Looking in command list: %v", c.commandList) if cmd, ok := ctx.Commands[name]; ok { return cmd } diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go index 6a679de..ef82c2f 100644 --- a/internal/common/ui/events/commands.go +++ b/internal/common/ui/events/commands.go @@ -10,6 +10,12 @@ func Error(err error) tea.Msg { return ErrorMsg(err) } +func SetError(err error) tea.Cmd { + return func() tea.Msg { + return Error(err) + } +} + func SetStatus(msg string) tea.Cmd { return func() tea.Msg { return StatusMsg(msg) @@ -25,6 +31,20 @@ func PromptForInput(prompt string, onDone func(value string) tea.Cmd) tea.Cmd { } } +func Confirm(prompt string, onYes func() tea.Cmd) tea.Cmd { + return PromptForInput(prompt, func(value string) tea.Cmd { + if value == "y" { + return onYes() + } + return nil + }) +} + type MessageWithStatus interface { StatusMessage() string } + +type MessageWithMode interface { + MessageWithStatus + ModeMessage() string +} diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go index 9688142..9aa3388 100644 --- a/internal/common/ui/events/errors.go +++ b/internal/common/ui/events/errors.go @@ -10,6 +10,9 @@ type ErrorMsg error // Message indicates that a message should be shown to the user type StatusMsg string +// ModeMessage indicates that the mode should be changed to the following +type ModeMessage string + // PromptForInput indicates that the context is requesting a line of input type PromptForInputMsg struct { Prompt string diff --git a/internal/common/ui/logging/debug.go b/internal/common/ui/logging/debug.go index 37decab..7ff1fc1 100644 --- a/internal/common/ui/logging/debug.go +++ b/internal/common/ui/logging/debug.go @@ -6,8 +6,18 @@ import ( "os" ) -func EnableLogging() (closeFn func()) { - f, err := tea.LogToFile("debug.log", "debug") +func EnableLogging(logFile string) (closeFn func()) { + if logFile == "" { + tempFile, err := os.CreateTemp("", "debug.log") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + tempFile.Close() + logFile = tempFile.Name() + } + + f, err := tea.LogToFile(logFile, "debug") if err != nil { fmt.Println("fatal:", err) os.Exit(1) diff --git a/internal/common/ui/osstyle/osstyle.go b/internal/common/ui/osstyle/osstyle.go new file mode 100644 index 0000000..e053f0b --- /dev/null +++ b/internal/common/ui/osstyle/osstyle.go @@ -0,0 +1,18 @@ +package osstyle + +type ColorScheme int + +const ( + ColorSchemeUnknown ColorScheme = iota + ColorSchemeLightMode + ColorSchemeDarkMode +) + +var getOSColorScheme func() ColorScheme = nil + +func CurrentColorScheme() ColorScheme { + if getOSColorScheme == nil { + return ColorSchemeUnknown + } + return getOSColorScheme() +} diff --git a/internal/common/ui/osstyle/osstyle_darwin.go b/internal/common/ui/osstyle/osstyle_darwin.go new file mode 100644 index 0000000..ef39429 --- /dev/null +++ b/internal/common/ui/osstyle/osstyle_darwin.go @@ -0,0 +1,27 @@ +package osstyle + +import ( + "log" + "os/exec" +) + +// Usage: https://stefan.sofa-rockers.org/2018/10/23/macos-dark-mode-terminal-vim/ +func darwinGetOSColorScheme() ColorScheme { + d, err := exec.Command("defaults", "read", "-g", "AppleInterfaceStyle").Output() + if err != nil { + log.Printf("cannot get current OS color scheme: %v", err) + return ColorSchemeUnknown + } + + switch string(d) { + case "Dark\n": + return ColorSchemeDarkMode + case "Light\n": + return ColorSchemeLightMode + } + return ColorSchemeUnknown +} + +func init() { + getOSColorScheme = darwinGetOSColorScheme +} diff --git a/internal/dynamo-browse/controllers/attrpath.go b/internal/dynamo-browse/controllers/attrpath.go new file mode 100644 index 0000000..09e191f --- /dev/null +++ b/internal/dynamo-browse/controllers/attrpath.go @@ -0,0 +1,96 @@ +package controllers + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/pkg/errors" + "strings" +) + +type attrPath []string + +func newAttrPath(expr string) attrPath { + return strings.Split(expr, ".") +} + +func (ap attrPath) follow(item models.Item) (types.AttributeValue, error) { + var step types.AttributeValue + for i, seg := range ap { + if i == 0 { + step = item[seg] + continue + } + + switch s := step.(type) { + case *types.AttributeValueMemberM: + step = s.Value[seg] + default: + return nil, errors.Errorf("seg %v expected to be a map", i) + } + } + return step, nil +} + +func (ap attrPath) deleteAt(item models.Item) error { + if len(ap) == 1 { + delete(item, ap[0]) + return nil + } + + var step types.AttributeValue + for i, seg := range ap[:len(ap)-1] { + if i == 0 { + step = item[seg] + continue + } + + switch s := step.(type) { + case *types.AttributeValueMemberM: + step = s.Value[seg] + default: + return errors.Errorf("seg %v expected to be a map", i) + } + } + + lastSeg := ap[len(ap)-1] + switch s := step.(type) { + case *types.AttributeValueMemberM: + delete(s.Value, lastSeg) + default: + return errors.Errorf("last seg expected to be a map, but was %T", lastSeg) + } + + return nil +} + +func (ap attrPath) setAt(item models.Item, newValue types.AttributeValue) error { + if len(ap) == 1 { + item[ap[0]] = newValue + return nil + } + + var step types.AttributeValue + for i, seg := range ap[:len(ap)-1] { + if i == 0 { + step = item[seg] + continue + } + + switch s := step.(type) { + case *types.AttributeValueMemberM: + step = s.Value[seg] + default: + return errors.Errorf("seg %v expected to be a map", i) + } + } + + lastSeg := ap[len(ap)-1] + switch s := step.(type) { + case *types.AttributeValueMemberM: + s.Value[lastSeg] = newValue + default: + return errors.Errorf("last seg expected to be a map, but was %T", lastSeg) + } + + return nil +} diff --git a/internal/dynamo-browse/controllers/commands.go b/internal/dynamo-browse/controllers/commands.go new file mode 100644 index 0000000..5b7c630 --- /dev/null +++ b/internal/dynamo-browse/controllers/commands.go @@ -0,0 +1,25 @@ +package controllers + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/events" +) + +type promptSequence struct { + prompts []string + receivedValues []string + onAllDone func(values []string) tea.Msg +} + +func (ps *promptSequence) next() tea.Msg { + if len(ps.receivedValues) < len(ps.prompts) { + return events.PromptForInputMsg{ + Prompt: ps.prompts[len(ps.receivedValues)], + OnDone: func(value string) tea.Cmd { + ps.receivedValues = append(ps.receivedValues, value) + return ps.next + }, + } + } + return ps.onAllDone(ps.receivedValues) +} diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index 441f05c..c8a3713 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -2,17 +2,42 @@ package controllers import ( "fmt" - tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/dynamo-browse/models" ) type NewResultSet struct { - ResultSet *models.ResultSet + ResultSet *models.ResultSet + currentFilter string + filteredCount int + statusMessage string +} + +func (rs NewResultSet) ModeMessage() string { + var modeLine string + + if rs.ResultSet.Query != nil { + modeLine = rs.ResultSet.Query.String() + } else { + modeLine = "All results" + } + + if rs.currentFilter != "" { + modeLine = fmt.Sprintf("%v - Filter: '%v'", modeLine, rs.currentFilter) + } + return modeLine } func (rs NewResultSet) StatusMessage() string { - return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items())) + if rs.statusMessage != "" { + return rs.statusMessage + } + + if rs.currentFilter != "" { + return fmt.Sprintf("%d of %d items returned", rs.filteredCount, len(rs.ResultSet.Items())) + } else { + return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items())) + } } type SetReadWrite struct { diff --git a/internal/dynamo-browse/controllers/iface.go b/internal/dynamo-browse/controllers/iface.go new file mode 100644 index 0000000..bf61064 --- /dev/null +++ b/internal/dynamo-browse/controllers/iface.go @@ -0,0 +1,14 @@ +package controllers + +import ( + "context" + "github.com/lmika/awstools/internal/dynamo-browse/models" +) + +type TableReadService interface { + ListTables(background context.Context) ([]string, error) + Describe(ctx context.Context, table string) (*models.TableInfo, error) + Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) + Filter(resultSet *models.ResultSet, filter string) *models.ResultSet + ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, query models.Queryable) (*models.ResultSet, error) +} diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go index a711e6d..acd1672 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -1,28 +1,69 @@ package controllers import ( - "context" + "sync" "github.com/lmika/awstools/internal/dynamo-browse/models" ) type State struct { - ResultSet *models.ResultSet - SelectedItem models.Item - - // InReadWriteMode indicates whether modifications can be made to the table - InReadWriteMode bool + mutex *sync.Mutex + resultSet *models.ResultSet + filter string } -type stateContextKeyType struct{} - -var stateContextKey = stateContextKeyType{} - -func CurrentState(ctx context.Context) State { - state, _ := ctx.Value(stateContextKey).(State) - return state +func NewState() *State { + return &State{ + mutex: new(sync.Mutex), + } } -func ContextWithState(ctx context.Context, state State) context.Context { - return context.WithValue(ctx, stateContextKey, state) +func (s *State) ResultSet() *models.ResultSet { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.resultSet +} + +func (s *State) Filter() string { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.filter +} + +func (s *State) withResultSet(rs func(*models.ResultSet)) { + s.mutex.Lock() + defer s.mutex.Unlock() + + rs(s.resultSet) +} + +func (s *State) withResultSetReturningError(rs func(*models.ResultSet) error) (err error) { + s.withResultSet(func(set *models.ResultSet) { + err = rs(set) + }) + return err +} + +func (s *State) setResultSetAndFilter(resultSet *models.ResultSet, filter string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.resultSet = resultSet + s.filter = filter +} + +func (s *State) buildNewResultSetMessage(statusMessage string) NewResultSet { + s.mutex.Lock() + defer s.mutex.Unlock() + + var filteredCount int = 0 + if s.filter != "" { + for i := range s.resultSet.Items() { + if !s.resultSet.Hidden(i) { + filteredCount += 1 + } + } + } + + return NewResultSet{s.resultSet, s.filter, filteredCount, statusMessage} } diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index d185179..a6e426e 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -2,26 +2,30 @@ package controllers import ( "context" + "encoding/csv" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/models" - "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr" "github.com/pkg/errors" + "os" "sync" ) type TableReadController struct { - tableService *tables.Service + tableService TableReadService tableName string // state - mutex *sync.Mutex - resultSet *models.ResultSet - filter string + mutex *sync.Mutex + state *State + //resultSet *models.ResultSet + //filter string } -func NewTableReadController(tableService *tables.Service, tableName string) *TableReadController { +func NewTableReadController(state *State, tableService TableReadService, tableName string) *TableReadController { return &TableReadController{ + state: state, tableService: tableService, tableName: tableName, mutex: new(sync.Mutex), @@ -67,55 +71,106 @@ func (c *TableReadController) ScanTable(name string) tea.Cmd { return events.Error(err) } - return c.setResultSetAndFilter(resultSet, c.filter) + return c.setResultSetAndFilter(resultSet, c.state.Filter()) + } +} + +func (c *TableReadController) PromptForQuery() tea.Cmd { + return func() tea.Msg { + return events.PromptForInputMsg{ + Prompt: "query: ", + OnDone: func(value string) tea.Cmd { + if value == "" { + return func() tea.Msg { + resultSet := c.state.ResultSet() + return c.doScan(context.Background(), resultSet, nil) + } + } + + expr, err := queryexpr.Parse(value) + if err != nil { + return events.SetError(err) + } + + return func() tea.Msg { + resultSet := c.state.ResultSet() + newResultSet, err := c.tableService.ScanOrQuery(context.Background(), resultSet.TableInfo, expr) + if err != nil { + return events.Error(err) + } + + return c.setResultSetAndFilter(newResultSet, "") + } + }, + } } } func (c *TableReadController) Rescan() tea.Cmd { return func() tea.Msg { - return c.doScan(context.Background(), c.resultSet) + resultSet := c.state.ResultSet() + return c.doScan(context.Background(), resultSet, resultSet.Query) } } -func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet) tea.Msg { - newResultSet, err := c.tableService.Scan(ctx, resultSet.TableInfo) +func (c *TableReadController) ExportCSV(filename string) tea.Cmd { + return func() tea.Msg { + resultSet := c.state.ResultSet() + if resultSet == nil { + return events.Error(errors.New("no result set")) + } + + f, err := os.Create(filename) + if err != nil { + return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename)) + } + defer f.Close() + + cw := csv.NewWriter(f) + defer cw.Flush() + + columns := resultSet.Columns() + if err := cw.Write(columns); err != nil { + return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename)) + } + + row := make([]string, len(columns)) + for _, item := range resultSet.Items() { + for i, col := range columns { + row[i], _ = item.AttributeValueAsString(col) + } + if err := cw.Write(row); err != nil { + return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename)) + } + } + + return nil + } +} + +func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable) tea.Msg { + newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query) if err != nil { return events.Error(err) } - newResultSet = c.tableService.Filter(newResultSet, c.filter) + newResultSet = c.tableService.Filter(newResultSet, c.state.Filter()) - return c.setResultSetAndFilter(newResultSet, c.filter) -} - -func (c *TableReadController) ResultSet() *models.ResultSet { - c.mutex.Lock() - defer c.mutex.Unlock() - - return c.resultSet + return c.setResultSetAndFilter(newResultSet, c.state.Filter()) } func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg { - c.mutex.Lock() - defer c.mutex.Unlock() - - c.resultSet = resultSet - c.filter = filter - return NewResultSet{resultSet} + c.state.setResultSetAndFilter(resultSet, filter) + return c.state.buildNewResultSetMessage("") } func (c *TableReadController) Unmark() tea.Cmd { return func() tea.Msg { - resultSet := c.ResultSet() - - for i := range resultSet.Items() { - resultSet.SetMark(i, false) - } - - c.mutex.Lock() - defer c.mutex.Unlock() - - c.resultSet = resultSet + c.state.withResultSet(func(resultSet *models.ResultSet) { + for i := range resultSet.Items() { + resultSet.SetMark(i, false) + } + }) return ResultSetUpdated{} } } @@ -126,7 +181,7 @@ func (c *TableReadController) Filter() tea.Cmd { Prompt: "filter: ", OnDone: func(value string) tea.Cmd { return func() tea.Msg { - resultSet := c.ResultSet() + resultSet := c.state.ResultSet() newResultSet := c.tableService.Filter(resultSet, value) return c.setResultSetAndFilter(newResultSet, value) diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go new file mode 100644 index 0000000..803c4bb --- /dev/null +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -0,0 +1,257 @@ +package controllers_test + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/dynamo-browse/controllers" + "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/lmika/awstools/test/testdynamo" + "github.com/stretchr/testify/assert" + "os" + "strings" + "testing" +) + +func TestTableReadController_InitTable(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + t.Run("should prompt for table if no table name provided", func(t *testing.T) { + readController := controllers.NewTableReadController(controllers.NewState(), service, "") + + cmd := readController.Init() + event := cmd() + + assert.IsType(t, controllers.PromptForTableMsg{}, event) + }) + + t.Run("should scan table if table name provided", func(t *testing.T) { + readController := controllers.NewTableReadController(controllers.NewState(), service, "") + + cmd := readController.Init() + event := cmd() + + assert.IsType(t, controllers.PromptForTableMsg{}, event) + }) +} + +func TestTableReadController_ListTables(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + readController := controllers.NewTableReadController(controllers.NewState(), service, "") + + t.Run("returns a list of tables", func(t *testing.T) { + cmd := readController.ListTables() + event := cmd().(controllers.PromptForTableMsg) + + assert.Equal(t, []string{"alpha-table", "bravo-table"}, event.Tables) + + selectedCmd := event.OnSelected("alpha-table") + selectedEvent := selectedCmd() + + resultSet := selectedEvent.(controllers.NewResultSet) + assert.Equal(t, "alpha-table", resultSet.ResultSet.TableInfo.Name) + assert.Equal(t, "pk", resultSet.ResultSet.TableInfo.Keys.PartitionKey) + assert.Equal(t, "sk", resultSet.ResultSet.TableInfo.Keys.SortKey) + }) +} + +func TestTableReadController_ExportCSV(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + readController := controllers.NewTableReadController(controllers.NewState(), service, "bravo-table") + + t.Run("should export result set to CSV file", func(t *testing.T) { + tempFile := tempFile(t) + + invokeCommand(t, readController.Init()) + invokeCommand(t, readController.ExportCSV(tempFile)) + + bts, err := os.ReadFile(tempFile) + assert.NoError(t, err) + + assert.Equal(t, string(bts), strings.Join([]string{ + "pk,sk,alpha,beta,gamma\n", + "abc,222,This is another some value,1231,\n", + "bbb,131,,2468,foobar\n", + "foo,bar,This is some value,,\n", + }, "")) + }) + + t.Run("should return error if result set is not set", func(t *testing.T) { + tempFile := tempFile(t) + readController := controllers.NewTableReadController(controllers.NewState(), service, "non-existant-table") + + invokeCommandExpectingError(t, readController.Init()) + invokeCommandExpectingError(t, readController.ExportCSV(tempFile)) + }) + + // Hidden items? +} + +func TestTableReadController_Query(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + readController := controllers.NewTableReadController(controllers.NewState(), service, "bravo-table") + + t.Run("should run scan with filter based on user query", func(t *testing.T) { + tempFile := tempFile(t) + + invokeCommand(t, readController.Init()) + invokeCommandWithPrompts(t, readController.PromptForQuery(), `pk ^= "abc"`) + invokeCommand(t, readController.ExportCSV(tempFile)) + + bts, err := os.ReadFile(tempFile) + assert.NoError(t, err) + + assert.Equal(t, string(bts), strings.Join([]string{ + "pk,sk,alpha,beta\n", + "abc,222,This is another some value,1231\n", + }, "")) + }) + + t.Run("should return error if result set is not set", func(t *testing.T) { + tempFile := tempFile(t) + readController := controllers.NewTableReadController(controllers.NewState(), service, "non-existant-table") + + invokeCommandExpectingError(t, readController.Init()) + invokeCommandExpectingError(t, readController.ExportCSV(tempFile)) + }) +} + +func tempFile(t *testing.T) string { + t.Helper() + + tempFile, err := os.CreateTemp("", "export.csv") + assert.NoError(t, err) + tempFile.Close() + + t.Cleanup(func() { + os.Remove(tempFile.Name()) + }) + + return tempFile.Name() +} + +func invokeCommand(t *testing.T, cmd tea.Cmd) tea.Msg { + msg := cmd() + + err, isErr := msg.(events.ErrorMsg) + if isErr { + assert.Fail(t, fmt.Sprintf("expected no error but got one: %v", err)) + } + return msg +} + +func invokeCommandWithPrompt(t *testing.T, cmd tea.Cmd, promptValue string) { + msg := cmd() + + pi, isPi := msg.(events.PromptForInputMsg) + if !isPi { + assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one")) + } + + invokeCommand(t, pi.OnDone(promptValue)) +} + +func invokeCommandWithPrompts(t *testing.T, cmd tea.Cmd, promptValues ...string) { + msg := cmd() + + for _, promptValue := range promptValues { + pi, isPi := msg.(events.PromptForInputMsg) + if !isPi { + assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one")) + } + + msg = invokeCommand(t, pi.OnDone(promptValue)) + } +} + +func invokeCommandWithPromptsExpectingError(t *testing.T, cmd tea.Cmd, promptValues ...string) { + msg := cmd() + + for _, promptValue := range promptValues { + pi, isPi := msg.(events.PromptForInputMsg) + if !isPi { + assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one")) + } + + msg = invokeCommand(t, pi.OnDone(promptValue)) + } + + _, isErr := msg.(events.ErrorMsg) + assert.True(t, isErr) +} + +func invokeCommandExpectingError(t *testing.T, cmd tea.Cmd) { + msg := cmd() + + _, isErr := msg.(events.ErrorMsg) + assert.True(t, isErr) +} + +var testData = []testdynamo.TestData{ + { + TableName: "alpha-table", + Data: []map[string]interface{}{ + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + "age": 23, + "address": map[string]any{ + "no": 123, + "street": "Fake st.", + }, + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, + }, + { + TableName: "bravo-table", + Data: []map[string]interface{}{ + { + "pk": "foo", + "sk": "bar", + "alpha": "This is some value", + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, + }, +} diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 4b56b5f..a6b2f2f 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -3,18 +3,23 @@ package controllers import ( "context" "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/pkg/errors" ) type TableWriteController struct { + state *State tableService *tables.Service tableReadControllers *TableReadController } -func NewTableWriteController(tableService *tables.Service, tableReadControllers *TableReadController) *TableWriteController { +func NewTableWriteController(state *State, tableService *tables.Service, tableReadControllers *TableReadController) *TableWriteController { return &TableWriteController{ + state: state, tableService: tableService, tableReadControllers: tableReadControllers, } @@ -22,16 +27,232 @@ func NewTableWriteController(tableService *tables.Service, tableReadControllers func (twc *TableWriteController) ToggleMark(idx int) tea.Cmd { return func() tea.Msg { - resultSet := twc.tableReadControllers.ResultSet() - resultSet.SetMark(idx, !resultSet.Marked(idx)) + twc.state.withResultSet(func(resultSet *models.ResultSet) { + resultSet.SetMark(idx, !resultSet.Marked(idx)) + }) return ResultSetUpdated{} } } +func (twc *TableWriteController) NewItem() tea.Cmd { + return func() tea.Msg { + // Work out which keys we need to prompt for + rs := twc.state.ResultSet() + + keyPrompts := &promptSequence{ + prompts: []string{rs.TableInfo.Keys.PartitionKey + ": "}, + } + if rs.TableInfo.Keys.SortKey != "" { + keyPrompts.prompts = append(keyPrompts.prompts, rs.TableInfo.Keys.SortKey+": ") + } + keyPrompts.onAllDone = func(values []string) tea.Msg { + twc.state.withResultSet(func(set *models.ResultSet) { + newItem := models.Item{} + + // TODO: deal with keys of different type + newItem[rs.TableInfo.Keys.PartitionKey] = &types.AttributeValueMemberS{Value: values[0]} + if len(values) == 2 { + newItem[rs.TableInfo.Keys.SortKey] = &types.AttributeValueMemberS{Value: values[1]} + } + + set.AddNewItem(newItem, models.ItemAttribute{ + New: true, + Dirty: true, + }) + }) + return twc.state.buildNewResultSetMessage("New item added") + } + + return keyPrompts.next() + } +} + +func (twc *TableWriteController) SetStringValue(idx int, key string) tea.Cmd { + return func() tea.Msg { + // Verify that the expression is valid + apPath := newAttrPath(key) + + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + _, err := apPath.follow(set.Items()[idx]) + return err + }); err != nil { + return events.Error(err) + } + + return events.PromptForInputMsg{ + Prompt: "string value: ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + err := apPath.setAt(set.Items()[idx], &types.AttributeValueMemberS{Value: value}) + if err != nil { + return err + } + + set.SetDirty(idx, true) + set.RefreshColumns() + return nil + }); err != nil { + return events.Error(err) + } + return ResultSetUpdated{} + } + }, + } + } +} + +func (twc *TableWriteController) SetNumberValue(idx int, key string) tea.Cmd { + return func() tea.Msg { + // Verify that the expression is valid + apPath := newAttrPath(key) + + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + _, err := apPath.follow(set.Items()[idx]) + return err + }); err != nil { + return events.Error(err) + } + + return events.PromptForInputMsg{ + Prompt: "number value: ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + err := apPath.setAt(set.Items()[idx], &types.AttributeValueMemberN{Value: value}) + if err != nil { + return err + } + + set.SetDirty(idx, true) + set.RefreshColumns() + return nil + }); err != nil { + return events.Error(err) + } + return ResultSetUpdated{} + } + }, + } + } +} + +func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Cmd { + return func() tea.Msg { + // Verify that the expression is valid + apPath := newAttrPath(key) + + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + _, err := apPath.follow(set.Items()[idx]) + return err + }); err != nil { + return events.Error(err) + } + + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + err := apPath.deleteAt(set.Items()[idx]) + if err != nil { + return err + } + + set.SetDirty(idx, true) + set.RefreshColumns() + return nil + }); err != nil { + return events.Error(err) + } + + return ResultSetUpdated{} + } +} + +func (twc *TableWriteController) PutItem(idx int) tea.Cmd { + return func() tea.Msg { + resultSet := twc.state.ResultSet() + if !resultSet.IsDirty(idx) { + return events.Error(errors.New("item is not dirty")) + } + + return events.PromptForInputMsg{ + Prompt: "put item? ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + if value != "y" { + return nil + } + + if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil { + return events.Error(err) + } + return ResultSetUpdated{} + } + }, + } + } +} + +func (twc *TableWriteController) TouchItem(idx int) tea.Cmd { + return func() tea.Msg { + resultSet := twc.state.ResultSet() + if resultSet.IsDirty(idx) { + return events.Error(errors.New("cannot touch dirty items")) + } + + return events.PromptForInputMsg{ + Prompt: "touch item? ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + if value != "y" { + return nil + } + + if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil { + return events.Error(err) + } + return ResultSetUpdated{} + } + }, + } + } +} + +func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Cmd { + return func() tea.Msg { + resultSet := twc.state.ResultSet() + if resultSet.IsDirty(idx) { + return events.Error(errors.New("cannot noisy touch dirty items")) + } + + return events.PromptForInputMsg{ + Prompt: "noisy touch item? ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + if value != "y" { + return nil + } + + item := resultSet.Items()[0] + if err := twc.tableService.Delete(ctx, resultSet.TableInfo, []models.Item{item}); err != nil { + return events.Error(err) + } + + if err := twc.tableService.Put(ctx, resultSet.TableInfo, item); err != nil { + return events.Error(err) + } + + return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query) + } + }, + } + } +} + func (twc *TableWriteController) DeleteMarked() tea.Cmd { return func() tea.Msg { - resultSet := twc.tableReadControllers.ResultSet() + resultSet := twc.state.ResultSet() markedItems := resultSet.MarkedItems() if len(markedItems) == 0 { @@ -51,7 +272,7 @@ func (twc *TableWriteController) DeleteMarked() tea.Cmd { return events.Error(err) } - return twc.tableReadControllers.doScan(ctx, resultSet) + return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query) } }, } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index ac0c49a..98c593e 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -1,199 +1,365 @@ package controllers_test import ( - "testing" - + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" "github.com/lmika/awstools/test/testdynamo" + "github.com/stretchr/testify/assert" + "testing" ) -func TestTableWriteController_ToggleReadWrite(t *testing.T) { - t.Skip("needs to be updated") +func TestTableWriteController_NewItem(t *testing.T) { + t.Run("should add an item with pk and sk set at the end of the result set", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() - /* - twc, _, closeFn := setupController(t) - t.Cleanup(closeFn) + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) - t.Run("should enabling read write if disabled", func(t *testing.T) { - ctx, uiCtx := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - InReadWriteMode: false, - }) + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) - err := twc.ToggleReadWrite().Execute(ctx) - assert.NoError(t, err) + invokeCommand(t, readController.Init()) + assert.Len(t, state.ResultSet().Items(), 3) - assert.Contains(t, uiCtx.Messages, controllers.SetReadWrite{NewValue: true}) - }) + // Prompt for keys + invokeCommandWithPrompts(t, writeController.NewItem(), "pk-value", "sk-value") - t.Run("should disable read write if enabled", func(t *testing.T) { - ctx, uiCtx := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - InReadWriteMode: true, - }) + newResultSet := state.ResultSet() + assert.Len(t, newResultSet.Items(), 4) + assert.Len(t, newResultSet.Items()[3], 2) - err := twc.ToggleReadWrite().Execute(ctx) - assert.NoError(t, err) - - assert.Contains(t, uiCtx.Messages, controllers.SetReadWrite{NewValue: false}) - }) - */ + pk, _ := newResultSet.Items()[3].AttributeValueAsString("pk") + sk, _ := newResultSet.Items()[3].AttributeValueAsString("sk") + assert.Equal(t, "pk-value", pk) + assert.Equal(t, "sk-value", sk) + assert.True(t, newResultSet.IsNew(3)) + assert.True(t, newResultSet.IsDirty(3)) + }) } -func TestTableWriteController_Delete(t *testing.T) { - /* - t.Run("should delete selected item if in read/write mode is inactive", func(t *testing.T) { - twc, ctrls, closeFn := setupController(t) - t.Cleanup(closeFn) +func TestTableWriteController_SetStringValue(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() - ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName) - assert.NoError(t, err) - - resultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, resultSet.Items, 3) - - ctx, uiCtx := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - ResultSet: resultSet, - SelectedItem: resultSet.Items[1], - InReadWriteMode: true, - }) - - op := twc.Delete() - - // Should prompt first - err = op.Execute(ctx) - assert.NoError(t, err) - - _ = uiCtx - - */ - /* - promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput) - assert.True(t, ok) - - // After prompt, continue to delete - err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "y")) - assert.NoError(t, err) - - afterResultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, afterResultSet.Items, 2) - assert.Contains(t, afterResultSet.Items, resultSet.Items[0]) - assert.NotContains(t, afterResultSet.Items, resultSet.Items[1]) - assert.Contains(t, afterResultSet.Items, resultSet.Items[2]) - */ - /* - }) - - t.Run("should not delete selected item if prompt is not y", func(t *testing.T) { - twc, ctrls, closeFn := setupController(t) - t.Cleanup(closeFn) - - ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName) - assert.NoError(t, err) - - resultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, resultSet.Items, 3) - - ctx, uiCtx := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - ResultSet: resultSet, - SelectedItem: resultSet.Items[1], - InReadWriteMode: true, - }) - - op := twc.Delete() - - // Should prompt first - err = op.Execute(ctx) - assert.NoError(t, err) - _ = uiCtx - */ - /* - promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput) - assert.True(t, ok) - - // After prompt, continue to delete - err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "n")) - assert.Error(t, err) - - afterResultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, afterResultSet.Items, 3) - assert.Contains(t, afterResultSet.Items, resultSet.Items[0]) - assert.Contains(t, afterResultSet.Items, resultSet.Items[1]) - assert.Contains(t, afterResultSet.Items, resultSet.Items[2]) - */ - /* - }) - - t.Run("should not delete if read/write mode is inactive", func(t *testing.T) { - tableWriteController, ctrls, closeFn := setupController(t) - t.Cleanup(closeFn) - - ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName) - assert.NoError(t, err) - - resultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, resultSet.Items, 3) - - ctx, _ := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - ResultSet: resultSet, - SelectedItem: resultSet.Items[1], - InReadWriteMode: false, - }) - - op := tableWriteController.Delete() - - err = op.Execute(ctx) - assert.Error(t, err) - }) - - */ -} - -type controller struct { - tableName string - tableService *tables.Service -} - -func setupController(t *testing.T) (*controllers.TableWriteController, controller, func()) { - tableName := "table-write-controller-table" - - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) provider := dynamo.NewProvider(client) - tableService := tables.NewService(provider) - tableReadController := controllers.NewTableReadController(tableService, tableName) - tableWriteController := controllers.NewTableWriteController(tableService, tableReadController) - return tableWriteController, controller{ - tableName: tableName, - tableService: tableService, - }, cleanupFn + service := tables.NewService(provider) + + t.Run("should change the value of a string field if already present", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") + + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "a new value", after) + assert.True(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should change the value of a string field within a map if already present", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + + beforeAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + beforeStreet := beforeAddress.Value["street"].(*types.AttributeValueMemberS).Value + + assert.Equal(t, "Fake st.", beforeStreet) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "address.street"), "Fiction rd.") + + afterAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + afterStreet := afterAddress.Value["street"].(*types.AttributeValueMemberS).Value + + assert.Equal(t, "Fiction rd.", afterStreet) + assert.True(t, state.ResultSet().IsDirty(0)) + }) } -var testData = testdynamo.TestData{ - { - "pk": "abc", - "sk": "222", - "alpha": "This is another some value", - "beta": 1231, - }, - { - "pk": "abc", - "sk": "111", - "alpha": "This is some value", - }, - { - "pk": "bbb", - "sk": "131", - "beta": 2468, - "gamma": "foobar", - }, +func TestTableWriteController_SetNumberValue(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + t.Run("should change the value of a number field if already present", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("age") + assert.Equal(t, "23", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandWithPrompt(t, writeController.SetNumberValue(0, "age"), "46") + + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("age") + assert.Equal(t, "46", after) + assert.True(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should change the value of a number field within a map if already present", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + + beforeAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + beforeStreet := beforeAddress.Value["no"].(*types.AttributeValueMemberN).Value + + assert.Equal(t, "123", beforeStreet) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandWithPrompt(t, writeController.SetNumberValue(0, "address.no"), "456") + + afterAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + afterStreet := afterAddress.Value["no"].(*types.AttributeValueMemberN).Value + + assert.Equal(t, "456", afterStreet) + assert.True(t, state.ResultSet().IsDirty(0)) + }) +} + +func TestTableWriteController_DeleteAttribute(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + t.Run("should delete top level attribute", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("age") + assert.Equal(t, "23", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommand(t, writeController.DeleteAttribute(0, "age")) + + _, hasAge := state.ResultSet().Items()[0]["age"] + assert.False(t, hasAge) + }) + + t.Run("should delete attribute of map", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + + beforeAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + beforeStreet := beforeAddress.Value["no"].(*types.AttributeValueMemberN).Value + + assert.Equal(t, "123", beforeStreet) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommand(t, writeController.DeleteAttribute(0, "address.no")) + + afterAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + _, hasStreet := afterAddress.Value["no"] + + assert.False(t, hasStreet) + }) +} + +func TestTableWriteController_PutItem(t *testing.T) { + t.Run("should put the selected item if dirty", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") + invokeCommandWithPrompt(t, writeController.PutItem(0), "y") + + // Rescan the table + invokeCommand(t, readController.Rescan()) + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "a new value", after) + assert.False(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should not put the selected item if user does not confirm", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item but do not put it + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") + invokeCommandWithPrompt(t, writeController.PutItem(0), "n") + + current, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "a new value", current) + assert.True(t, state.ResultSet().IsDirty(0)) + + // Rescan the table to confirm item is not modified + invokeCommand(t, readController.Rescan()) + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", after) + assert.False(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should not put the selected item if not dirty", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandExpectingError(t, writeController.PutItem(0)) + }) +} + +func TestTableWriteController_TouchItem(t *testing.T) { + t.Run("should put the selected item if unmodified", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.TouchItem(0), "y") + + // Rescan the table + invokeCommand(t, readController.Rescan()) + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", after) + assert.False(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should not put the selected item if modified", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") + invokeCommandExpectingError(t, writeController.TouchItem(0)) + }) +} + +func TestTableWriteController_NoisyTouchItem(t *testing.T) { + t.Run("should delete and put the selected item if unmodified", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.NoisyTouchItem(0), "y") + + // Rescan the table + invokeCommand(t, readController.Rescan()) + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", after) + assert.False(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should not put the selected item if modified", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") + invokeCommandExpectingError(t, writeController.NoisyTouchItem(0)) + }) } diff --git a/internal/dynamo-browse/models/itemrender/coll.go b/internal/dynamo-browse/models/itemrender/coll.go new file mode 100644 index 0000000..3a015e1 --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/coll.go @@ -0,0 +1,60 @@ +package itemrender + +import ( + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "sort" +) + +type ListRenderer types.AttributeValueMemberL + +func (sr *ListRenderer) TypeName() string { + return "L" +} + +func (sr *ListRenderer) StringValue() string { + return "" +} + +func (sr *ListRenderer) MetaInfo() string { + if len(sr.Value) == 1 { + return fmt.Sprintf("(1 item)") + } + return fmt.Sprintf("(%d items)", len(sr.Value)) +} + +func (sr *ListRenderer) SubItems() []SubItem { + subitems := make([]SubItem, len(sr.Value)) + for i, r := range sr.Value { + subitems[i] = SubItem{Key: fmt.Sprint(i), Value: ToRenderer(r)} + } + return subitems +} + +type MapRenderer types.AttributeValueMemberM + +func (sr *MapRenderer) TypeName() string { + return "M" +} + +func (sr *MapRenderer) StringValue() string { + return "" +} + +func (sr *MapRenderer) MetaInfo() string { + if len(sr.Value) == 1 { + return fmt.Sprintf("(1 item)") + } + return fmt.Sprintf("(%d items)", len(sr.Value)) +} + +func (sr *MapRenderer) SubItems() []SubItem { + subitems := make([]SubItem, 0) + for k, r := range sr.Value { + subitems = append(subitems, SubItem{Key: k, Value: ToRenderer(r)}) + } + sort.Slice(subitems, func(i, j int) bool { + return subitems[i].Key < subitems[j].Key + }) + return subitems +} diff --git a/internal/dynamo-browse/models/itemrender/itemdisp.go b/internal/dynamo-browse/models/itemrender/itemdisp.go new file mode 100644 index 0000000..dd1f17f --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/itemdisp.go @@ -0,0 +1,50 @@ +package itemrender + +import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + +type Renderer interface { + TypeName() string + StringValue() string + MetaInfo() string + SubItems() []SubItem +} + +func ToRenderer(v types.AttributeValue) Renderer { + switch colVal := v.(type) { + case nil: + return nil + case *types.AttributeValueMemberS: + x := StringRenderer(*colVal) + return &x + case *types.AttributeValueMemberN: + x := NumberRenderer(*colVal) + return &x + case *types.AttributeValueMemberBOOL: + x := BoolRenderer(*colVal) + return &x + case *types.AttributeValueMemberNULL: + x := NullRenderer(*colVal) + return &x + case *types.AttributeValueMemberB: + x := BinaryRenderer(*colVal) + return &x + case *types.AttributeValueMemberL: + x := ListRenderer(*colVal) + return &x + case *types.AttributeValueMemberM: + x := MapRenderer(*colVal) + return &x + case *types.AttributeValueMemberBS: + return newBinarySetRenderer(colVal) + case *types.AttributeValueMemberNS: + return newNumberSetRenderer(colVal) + case *types.AttributeValueMemberSS: + return newStringSetRenderer(colVal) + } + return OtherRenderer{} +} + +type SubItem struct { + Key string + Value Renderer +} diff --git a/internal/dynamo-browse/models/itemrender/nils.go b/internal/dynamo-browse/models/itemrender/nils.go new file mode 100644 index 0000000..9171f31 --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/nils.go @@ -0,0 +1,19 @@ +package itemrender + +type OtherRenderer struct{} + +func (u OtherRenderer) TypeName() string { + return "??" +} + +func (sr OtherRenderer) StringValue() string { + return "" +} + +func (u OtherRenderer) MetaInfo() string { + return "(unrecognised)" +} + +func (u OtherRenderer) SubItems() []SubItem { + return nil +} diff --git a/internal/dynamo-browse/models/itemrender/scalars.go b/internal/dynamo-browse/models/itemrender/scalars.go new file mode 100644 index 0000000..9f67fe5 --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/scalars.go @@ -0,0 +1,98 @@ +package itemrender + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +type StringRenderer types.AttributeValueMemberS + +func (sr *StringRenderer) TypeName() string { + return "S" +} + +func (sr *StringRenderer) StringValue() string { + return sr.Value +} + +func (sr *StringRenderer) MetaInfo() string { + return "" +} + +func (sr *StringRenderer) SubItems() []SubItem { + return nil +} + +type NumberRenderer types.AttributeValueMemberN + +func (sr *NumberRenderer) TypeName() string { + return "N" +} + +func (sr *NumberRenderer) StringValue() string { + return sr.Value +} + +func (sr *NumberRenderer) MetaInfo() string { + return "" +} + +func (sr *NumberRenderer) SubItems() []SubItem { + return nil +} + +type BoolRenderer types.AttributeValueMemberBOOL + +func (sr *BoolRenderer) TypeName() string { + return "BOOL" +} + +func (sr *BoolRenderer) StringValue() string { + if sr.Value { + return "True" + } + return "False" +} + +func (sr *BoolRenderer) MetaInfo() string { + return "" +} + +func (sr *BoolRenderer) SubItems() []SubItem { + return nil +} + +type BinaryRenderer types.AttributeValueMemberB + +func (sr *BinaryRenderer) TypeName() string { + return "B" +} + +func (sr *BinaryRenderer) StringValue() string { + return "" +} + +func (sr *BinaryRenderer) MetaInfo() string { + return cardinality(len(sr.Value), "byte", "bytes") +} + +func (sr *BinaryRenderer) SubItems() []SubItem { + return nil +} + +type NullRenderer types.AttributeValueMemberNULL + +func (sr *NullRenderer) TypeName() string { + return "NULL" +} + +func (sr *NullRenderer) MetaInfo() string { + return "" +} + +func (sr *NullRenderer) StringValue() string { + return "null" +} + +func (sr *NullRenderer) SubItems() []SubItem { + return nil +} diff --git a/internal/dynamo-browse/models/itemrender/sets.go b/internal/dynamo-browse/models/itemrender/sets.go new file mode 100644 index 0000000..209b1fa --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/sets.go @@ -0,0 +1,55 @@ +package itemrender + +import ( + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +type GenericRenderer struct { + typeName string + subitemValue []Renderer +} + +func (sr *GenericRenderer) TypeName() string { + return sr.typeName +} + +func (sr *GenericRenderer) StringValue() string { + return "" +} + +func (sr *GenericRenderer) MetaInfo() string { + return cardinality(len(sr.subitemValue), "item", "items") +} + +func (sr *GenericRenderer) SubItems() []SubItem { + subitems := make([]SubItem, len(sr.subitemValue)) + for i, r := range sr.subitemValue { + subitems[i] = SubItem{Key: fmt.Sprint(i), Value: r} + } + return subitems +} + +func newBinarySetRenderer(v *types.AttributeValueMemberBS) *GenericRenderer { + vs := make([]Renderer, len(v.Value)) + for i, b := range v.Value { + vs[i] = &BinaryRenderer{Value: b} + } + return &GenericRenderer{typeName: "BS", subitemValue: vs} +} + +func newNumberSetRenderer(v *types.AttributeValueMemberNS) *GenericRenderer { + vs := make([]Renderer, len(v.Value)) + for i, n := range v.Value { + vs[i] = &NumberRenderer{Value: n} + } + return &GenericRenderer{typeName: "NS", subitemValue: vs} +} + +func newStringSetRenderer(v *types.AttributeValueMemberSS) *GenericRenderer { + vs := make([]Renderer, len(v.Value)) + for i, s := range v.Value { + vs[i] = &StringRenderer{Value: s} + } + return &GenericRenderer{typeName: "SS", subitemValue: vs} +} diff --git a/internal/dynamo-browse/models/itemrender/utils.go b/internal/dynamo-browse/models/itemrender/utils.go new file mode 100644 index 0000000..061a636 --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/utils.go @@ -0,0 +1,10 @@ +package itemrender + +import "fmt" + +func cardinality(c int, single, multi string) string { + if c == 1 { + return fmt.Sprintf("(%d %v)", c, single) + } + return fmt.Sprintf("(%d %v)", c, multi) +} diff --git a/internal/dynamo-browse/models/items.go b/internal/dynamo-browse/models/items.go index 13a0b6a..d49d0d6 100644 --- a/internal/dynamo-browse/models/items.go +++ b/internal/dynamo-browse/models/items.go @@ -1,6 +1,9 @@ package models -import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models/itemrender" +) type Item map[string]types.AttributeValue @@ -25,6 +28,10 @@ func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue { return itemKey } -func (i Item) AttributeValueAsString(k string) (string, bool) { - return attributeToString(i[k]) +func (i Item) AttributeValueAsString(key string) (string, bool) { + return attributeToString(i[key]) +} + +func (i Item) Renderer(key string) itemrender.Renderer { + return itemrender.ToRenderer(i[key]) } diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index e709e1b..ab82aa0 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -1,15 +1,27 @@ package models +import "sort" + type ResultSet struct { - TableInfo *TableInfo - Columns []string + TableInfo *TableInfo + Query Queryable + //Columns []string items []Item attributes []ItemAttribute + + columns []string +} + +type Queryable interface { + String() string + Plan(tableInfo *TableInfo) (*QueryExecutionPlan, error) } type ItemAttribute struct { Marked bool Hidden bool + Dirty bool + New bool } func (rs *ResultSet) Items() []Item { @@ -21,6 +33,11 @@ func (rs *ResultSet) SetItems(items []Item) { rs.attributes = make([]ItemAttribute, len(items)) } +func (rs *ResultSet) AddNewItem(item Item, attrs ItemAttribute) { + rs.items = append(rs.items, item) + rs.attributes = append(rs.attributes, attrs) +} + func (rs *ResultSet) SetMark(idx int, marked bool) { rs.attributes[idx].Marked = marked } @@ -29,6 +46,14 @@ func (rs *ResultSet) SetHidden(idx int, hidden bool) { rs.attributes[idx].Hidden = hidden } +func (rs *ResultSet) SetDirty(idx int, dirty bool) { + rs.attributes[idx].Dirty = dirty +} + +func (rs *ResultSet) SetNew(idx int, isNew bool) { + rs.attributes[idx].New = isNew +} + func (rs *ResultSet) Marked(idx int) bool { return rs.attributes[idx].Marked } @@ -37,6 +62,14 @@ func (rs *ResultSet) Hidden(idx int) bool { return rs.attributes[idx].Hidden } +func (rs *ResultSet) IsDirty(idx int) bool { + return rs.attributes[idx].Dirty +} + +func (rs *ResultSet) IsNew(idx int) bool { + return rs.attributes[idx].New +} + func (rs *ResultSet) MarkedItems() []Item { items := make([]Item, 0) for i, itemAttr := range rs.attributes { @@ -46,3 +79,46 @@ func (rs *ResultSet) MarkedItems() []Item { } return items } + +func (rs *ResultSet) Columns() []string { + if rs.columns == nil { + rs.RefreshColumns() + } + return rs.columns +} + +func (rs *ResultSet) RefreshColumns() { + seenColumns := make(map[string]int) + seenColumns[rs.TableInfo.Keys.PartitionKey] = 0 + if rs.TableInfo.Keys.SortKey != "" { + seenColumns[rs.TableInfo.Keys.SortKey] = 1 + } + + for _, definedAttribute := range rs.TableInfo.DefinedAttributes { + if _, seen := seenColumns[definedAttribute]; !seen { + seenColumns[definedAttribute] = len(seenColumns) + } + } + + otherColsRank := len(seenColumns) + for _, result := range rs.items { + for k := range result { + if _, isSeen := seenColumns[k]; !isSeen { + seenColumns[k] = otherColsRank + } + } + } + + columns := make([]string, 0, len(seenColumns)) + for k := range seenColumns { + columns = append(columns, k) + } + sort.Slice(columns, func(i, j int) bool { + if seenColumns[columns[i]] == seenColumns[columns[j]] { + return columns[i] < columns[j] + } + return seenColumns[columns[i]] < seenColumns[columns[j]] + }) + + rs.columns = columns +} diff --git a/internal/dynamo-browse/models/query.go b/internal/dynamo-browse/models/query.go new file mode 100644 index 0000000..cecde72 --- /dev/null +++ b/internal/dynamo-browse/models/query.go @@ -0,0 +1,8 @@ +package models + +import "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + +type QueryExecutionPlan struct { + CanQuery bool + Expression expression.Expression +} diff --git a/internal/dynamo-browse/models/queryexpr/ast.go b/internal/dynamo-browse/models/queryexpr/ast.go new file mode 100644 index 0000000..d8c9912 --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/ast.go @@ -0,0 +1,32 @@ +package queryexpr + +import ( + "github.com/alecthomas/participle/v2" + "github.com/pkg/errors" +) + +type astExpr struct { + Equality *astBinOp `parser:"@@"` +} + +type astBinOp struct { + Name string `parser:"@Ident"` + Op string `parser:"@('^' '=' | '=')"` + Value *astLiteralValue `parser:"@@"` +} + +type astLiteralValue struct { + StringVal string `parser:"@String"` +} + +var parser = participle.MustBuild(&astExpr{}) + +func Parse(expr string) (*QueryExpr, error) { + var ast astExpr + + if err := parser.ParseString("expr", expr, &ast); err != nil { + return nil, errors.Wrapf(err, "cannot parse expression: '%v'", expr) + } + + return &QueryExpr{ast: &ast}, nil +} diff --git a/internal/dynamo-browse/models/queryexpr/calcquery.go b/internal/dynamo-browse/models/queryexpr/calcquery.go new file mode 100644 index 0000000..c2e2dbe --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/calcquery.go @@ -0,0 +1,52 @@ +package queryexpr + +import ( + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/pkg/errors" +) + +func (a *astExpr) calcQuery(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) { + return a.Equality.calcQuery(tableInfo) +} + +func (a *astBinOp) calcQuery(info *models.TableInfo) (*models.QueryExecutionPlan, error) { + // TODO: check if can be a query + cb, err := a.calcQueryForScan(info) + if err != nil { + return nil, err + } + + builder := expression.NewBuilder() + builder = builder.WithFilter(cb) + + expr, err := builder.Build() + if err != nil { + return nil, err + } + + return &models.QueryExecutionPlan{ + CanQuery: false, + Expression: expr, + }, nil +} + +func (a *astBinOp) calcQueryForScan(info *models.TableInfo) (expression.ConditionBuilder, error) { + v, err := a.Value.goValue() + if err != nil { + return expression.ConditionBuilder{}, err + } + + switch a.Op { + case "=": + return expression.Name(a.Name).Equal(expression.Value(v)), nil + case "^=": + strValue, isStrValue := v.(string) + if !isStrValue { + return expression.ConditionBuilder{}, errors.New("operand '^=' must be string") + } + return expression.Name(a.Name).BeginsWith(strValue), nil + } + + return expression.ConditionBuilder{}, errors.Errorf("unrecognised operator: %v", a.Op) +} diff --git a/internal/dynamo-browse/models/queryexpr/expr.go b/internal/dynamo-browse/models/queryexpr/expr.go new file mode 100644 index 0000000..2916c15 --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/expr.go @@ -0,0 +1,15 @@ +package queryexpr + +import "github.com/lmika/awstools/internal/dynamo-browse/models" + +type QueryExpr struct { + ast *astExpr +} + +func (md *QueryExpr) Plan(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) { + return md.ast.calcQuery(tableInfo) +} + +func (md *QueryExpr) String() string { + return md.ast.String() +} diff --git a/internal/dynamo-browse/models/queryexpr/expr_test.go b/internal/dynamo-browse/models/queryexpr/expr_test.go new file mode 100644 index 0000000..f3a7b4b --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/expr_test.go @@ -0,0 +1,47 @@ +package queryexpr_test + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr" + "testing" + + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/stretchr/testify/assert" +) + +func TestModExpr_Query(t *testing.T) { + tableInfo := &models.TableInfo{ + Name: "test", + Keys: models.KeyAttribute{ + PartitionKey: "pk", + SortKey: "sk", + }, + } + + t.Run("perform query when request pk is fixed", func(t *testing.T) { + modExpr, err := queryexpr.Parse(`pk="prefix"`) + assert.NoError(t, err) + + plan, err := modExpr.Plan(tableInfo) + assert.NoError(t, err) + + assert.False(t, plan.CanQuery) + assert.Equal(t, "#0 = :0", aws.ToString(plan.Expression.Filter())) + assert.Equal(t, "pk", plan.Expression.Names()["#0"]) + assert.Equal(t, "prefix", plan.Expression.Values()[":0"].(*types.AttributeValueMemberS).Value) + }) + + t.Run("perform scan when request pk prefix", func(t *testing.T) { + modExpr, err := queryexpr.Parse(`pk^="prefix"`) // TODO: fix this so that '^ =' is invalid + assert.NoError(t, err) + + plan, err := modExpr.Plan(tableInfo) + assert.NoError(t, err) + + assert.False(t, plan.CanQuery) + assert.Equal(t, "begins_with (#0, :0)", aws.ToString(plan.Expression.Filter())) + assert.Equal(t, "pk", plan.Expression.Names()["#0"]) + assert.Equal(t, "prefix", plan.Expression.Values()[":0"].(*types.AttributeValueMemberS).Value) + }) +} diff --git a/internal/dynamo-browse/models/queryexpr/tostr.go b/internal/dynamo-browse/models/queryexpr/tostr.go new file mode 100644 index 0000000..9453954 --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/tostr.go @@ -0,0 +1,13 @@ +package queryexpr + +func (a *astExpr) String() string { + return a.Equality.String() +} + +func (a *astBinOp) String() string { + return a.Name + a.Op + a.Value.String() +} + +func (a *astLiteralValue) String() string { + return a.StringVal +} diff --git a/internal/dynamo-browse/models/queryexpr/values.go b/internal/dynamo-browse/models/queryexpr/values.go new file mode 100644 index 0000000..8bb0e81 --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/values.go @@ -0,0 +1,24 @@ +package queryexpr + +import ( + "strconv" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/pkg/errors" +) + +func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { + s, err := strconv.Unquote(a.StringVal) + if err != nil { + return nil, errors.Wrap(err, "cannot unquote string") + } + return &types.AttributeValueMemberS{Value: s}, nil +} + +func (a *astLiteralValue) goValue() (any, error) { + s, err := strconv.Unquote(a.StringVal) + if err != nil { + return nil, errors.Wrap(err, "cannot unquote string") + } + return s, nil +} diff --git a/internal/dynamo-browse/providers/dynamo/provider.go b/internal/dynamo-browse/providers/dynamo/provider.go index 4c74d74..e05cd5f 100644 --- a/internal/dynamo-browse/providers/dynamo/provider.go +++ b/internal/dynamo-browse/providers/dynamo/provider.go @@ -2,8 +2,8 @@ package dynamo import ( "context" - "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/awstools/internal/dynamo-browse/models" @@ -64,17 +64,34 @@ func NewProvider(client *dynamodb.Client) *Provider { return &Provider{client: client} } -func (p *Provider) ScanItems(ctx context.Context, tableName string) ([]models.Item, error) { - res, err := p.client.Scan(ctx, &dynamodb.ScanInput{ +func (p *Provider) ScanItems(ctx context.Context, tableName string, filterExpr *expression.Expression, maxItems int) ([]models.Item, error) { + input := &dynamodb.ScanInput{ TableName: aws.String(tableName), - }) - if err != nil { - return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName) + Limit: aws.Int32(int32(maxItems)), + } + if filterExpr != nil { + input.FilterExpression = filterExpr.Filter() + input.ExpressionAttributeNames = filterExpr.Names() + input.ExpressionAttributeValues = filterExpr.Values() } - items := make([]models.Item, len(res.Items)) - for i, itm := range res.Items { - items[i] = itm + paginator := dynamodb.NewScanPaginator(p.client, input) + + items := make([]models.Item, 0) + +outer: + for paginator.HasMorePages() { + res, err := paginator.NextPage(ctx) + if err != nil { + return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName) + } + + for _, itm := range res.Items { + items = append(items, itm) + if len(items) >= maxItems { + break outer + } + } } return items, nil diff --git a/internal/dynamo-browse/providers/dynamo/provider_test.go b/internal/dynamo-browse/providers/dynamo/provider_test.go index a408bc2..119da34 100644 --- a/internal/dynamo-browse/providers/dynamo/provider_test.go +++ b/internal/dynamo-browse/providers/dynamo/provider_test.go @@ -11,38 +11,38 @@ import ( ) func TestProvider_ScanItems(t *testing.T) { - tableName := "provider-scanimages-test-table" + tableName := "test-table" - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) t.Run("should return scanned items from the table", func(t *testing.T) { ctx := context.Background() - items, err := provider.ScanItems(ctx, tableName) + items, err := provider.ScanItems(ctx, tableName, nil, 100) assert.NoError(t, err) assert.Len(t, items, 3) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[1])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[0])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[1])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[2])) }) t.Run("should return error if table name does not exist", func(t *testing.T) { ctx := context.Background() - items, err := provider.ScanItems(ctx, "does-not-exist") + items, err := provider.ScanItems(ctx, "does-not-exist", nil, 100) assert.Error(t, err) assert.Nil(t, items) }) } func TestProvider_DeleteItem(t *testing.T) { - tableName := "provider-deleteitem-test-table" + tableName := "test-table" t.Run("should delete item if exists in table", func(t *testing.T) { - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -53,18 +53,18 @@ func TestProvider_DeleteItem(t *testing.T) { "sk": &types.AttributeValueMemberS{Value: "222"}, }) - items, err := provider.ScanItems(ctx, tableName) + items, err := provider.ScanItems(ctx, tableName, nil, 100) assert.NoError(t, err) assert.Len(t, items, 2) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2])) - assert.NotContains(t, items, testdynamo.TestRecordAsItem(t, testData[1])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[0])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[2])) + assert.NotContains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[1])) }) t.Run("should do nothing if key does not exist", func(t *testing.T) { - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -75,44 +75,49 @@ func TestProvider_DeleteItem(t *testing.T) { "sk": &types.AttributeValueMemberS{Value: "999"}, }) - items, err := provider.ScanItems(ctx, tableName) + items, err := provider.ScanItems(ctx, tableName, nil, 100) assert.NoError(t, err) assert.Len(t, items, 3) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[1])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[0])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[1])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[2])) }) t.Run("should return error if table name does not exist", func(t *testing.T) { - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) ctx := context.Background() - items, err := provider.ScanItems(ctx, "does-not-exist") + items, err := provider.ScanItems(ctx, "does-not-exist", nil, 100) assert.Error(t, err) assert.Nil(t, items) }) } -var testData = testdynamo.TestData{ +var testData = []testdynamo.TestData{ { - "pk": "abc", - "sk": "111", - "alpha": "This is some value", - }, - { - "pk": "abc", - "sk": "222", - "alpha": "This is another some value", - "beta": 1231, - }, - { - "pk": "bbb", - "sk": "131", - "beta": 2468, - "gamma": "foobar", + TableName: "test-table", + Data: []map[string]interface{}{ + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, }, } diff --git a/internal/dynamo-browse/services/tables/iface.go b/internal/dynamo-browse/services/tables/iface.go index 2dddc6c..58d63ec 100644 --- a/internal/dynamo-browse/services/tables/iface.go +++ b/internal/dynamo-browse/services/tables/iface.go @@ -2,6 +2,7 @@ package tables import ( "context" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/awstools/internal/dynamo-browse/models" @@ -10,7 +11,7 @@ import ( type TableProvider interface { ListTables(ctx context.Context) ([]string, error) DescribeTable(ctx context.Context, tableName string) (*models.TableInfo, error) - ScanItems(ctx context.Context, tableName string) ([]models.Item, error) + ScanItems(ctx context.Context, tableName string, filterExpr *expression.Expression, maxItems int) ([]models.Item, error) DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error PutItem(ctx context.Context, name string, item models.Item) error } diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index 12ee69b..d695c08 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -2,7 +2,7 @@ package tables import ( "context" - "sort" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "strings" "github.com/lmika/awstools/internal/dynamo-browse/models" @@ -28,51 +28,73 @@ func (s *Service) Describe(ctx context.Context, table string) (*models.TableInfo } func (s *Service) Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) { - results, err := s.provider.ScanItems(ctx, tableInfo.Name) + return s.doScan(ctx, tableInfo, nil) +} + +func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, expr models.Queryable) (*models.ResultSet, error) { + var filterExpr *expression.Expression + + if expr != nil { + plan, err := expr.Plan(tableInfo) + if err != nil { + return nil, err + } + + // TEMP + if plan.CanQuery { + return nil, errors.Errorf("queries not yet supported") + } + + filterExpr = &plan.Expression + } + + results, err := s.provider.ScanItems(ctx, tableInfo.Name, filterExpr, 1000) if err != nil { return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name) } // Get the columns - seenColumns := make(map[string]int) - seenColumns[tableInfo.Keys.PartitionKey] = 0 - if tableInfo.Keys.SortKey != "" { - seenColumns[tableInfo.Keys.SortKey] = 1 - } - - for _, definedAttribute := range tableInfo.DefinedAttributes { - if _, seen := seenColumns[definedAttribute]; !seen { - seenColumns[definedAttribute] = len(seenColumns) - } - } - - otherColsRank := len(seenColumns) - for _, result := range results { - for k := range result { - if _, isSeen := seenColumns[k]; !isSeen { - seenColumns[k] = otherColsRank - } - } - } - - columns := make([]string, 0, len(seenColumns)) - for k := range seenColumns { - columns = append(columns, k) - } - sort.Slice(columns, func(i, j int) bool { - if seenColumns[columns[i]] == seenColumns[columns[j]] { - return columns[i] < columns[j] - } - return seenColumns[columns[i]] < seenColumns[columns[j]] - }) + //seenColumns := make(map[string]int) + //seenColumns[tableInfo.Keys.PartitionKey] = 0 + //if tableInfo.Keys.SortKey != "" { + // seenColumns[tableInfo.Keys.SortKey] = 1 + //} + // + //for _, definedAttribute := range tableInfo.DefinedAttributes { + // if _, seen := seenColumns[definedAttribute]; !seen { + // seenColumns[definedAttribute] = len(seenColumns) + // } + //} + // + //otherColsRank := len(seenColumns) + //for _, result := range results { + // for k := range result { + // if _, isSeen := seenColumns[k]; !isSeen { + // seenColumns[k] = otherColsRank + // } + // } + //} + // + //columns := make([]string, 0, len(seenColumns)) + //for k := range seenColumns { + // columns = append(columns, k) + //} + //sort.Slice(columns, func(i, j int) bool { + // if seenColumns[columns[i]] == seenColumns[columns[j]] { + // return columns[i] < columns[j] + // } + // return seenColumns[columns[i]] < seenColumns[columns[j]] + //}) models.Sort(results, tableInfo) resultSet := &models.ResultSet{ TableInfo: tableInfo, - Columns: columns, + Query: expr, + //Columns: columns, } resultSet.SetItems(results) + resultSet.RefreshColumns() return resultSet, nil } @@ -81,6 +103,17 @@ func (s *Service) Put(ctx context.Context, tableInfo *models.TableInfo, item mod return s.provider.PutItem(ctx, tableInfo.Name, item) } +func (s *Service) PutItemAt(ctx context.Context, resultSet *models.ResultSet, index int) error { + item := resultSet.Items()[index] + if err := s.provider.PutItem(ctx, resultSet.TableInfo.Name, item); err != nil { + return err + } + + resultSet.SetDirty(index, false) + resultSet.SetNew(index, false) + return nil +} + func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items []models.Item) error { for _, item := range items { if err := s.provider.DeleteItem(ctx, tableInfo.Name, item.KeyValue(tableInfo)); err != nil { @@ -90,6 +123,10 @@ func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items return nil } +func (s *Service) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, expr models.Queryable) (*models.ResultSet, error) { + return s.doScan(ctx, tableInfo, expr) +} + // TODO: move into a new service func (s *Service) Filter(resultSet *models.ResultSet, filter string) *models.ResultSet { for i, item := range resultSet.Items() { diff --git a/internal/dynamo-browse/services/tables/service_test.go b/internal/dynamo-browse/services/tables/service_test.go index 9edc13c..855ef1c 100644 --- a/internal/dynamo-browse/services/tables/service_test.go +++ b/internal/dynamo-browse/services/tables/service_test.go @@ -11,9 +11,9 @@ import ( ) func TestService_Describe(t *testing.T) { - tableName := "service-describe-table" + tableName := "service-test-data" - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -33,9 +33,9 @@ func TestService_Describe(t *testing.T) { } func TestService_Scan(t *testing.T) { - tableName := "service-scan-test-table" + tableName := "service-test-data" - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -51,29 +51,34 @@ func TestService_Scan(t *testing.T) { // Hash first, then range, then columns in alphabetic order assert.Equal(t, rs.TableInfo, ti) - assert.Equal(t, rs.Columns, []string{"pk", "sk", "alpha", "beta", "gamma"}) + assert.Equal(t, rs.Columns(), []string{"pk", "sk", "alpha", "beta", "gamma"}) //assert.Equal(t, rs.Items[0], testdynamo.TestRecordAsItem(t, testData[1])) //assert.Equal(t, rs.Items[1], testdynamo.TestRecordAsItem(t, testData[0])) //assert.Equal(t, rs.Items[2], testdynamo.TestRecordAsItem(t, testData[2])) }) } -var testData = testdynamo.TestData{ +var testData = []testdynamo.TestData{ { - "pk": "abc", - "sk": "222", - "alpha": "This is another some value", - "beta": 1231, - }, - { - "pk": "abc", - "sk": "111", - "alpha": "This is some value", - }, - { - "pk": "bbb", - "sk": "131", - "beta": 2468, - "gamma": "foobar", + TableName: "service-test-data", + Data: []map[string]interface{}{ + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, }, } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 8d63862..83ccb45 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -3,13 +3,17 @@ package ui import ( tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/commandctrl" + "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemedit" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dialogprompt" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamotableview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/tableselect" + "github.com/pkg/errors" ) type Model struct { @@ -25,13 +29,16 @@ type Model struct { } func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteController, cc *commandctrl.CommandController) Model { - dtv := dynamotableview.New() - div := dynamoitemview.New() + uiStyles := styles.DefaultStyles + + dtv := dynamotableview.New(uiStyles) + div := dynamoitemview.New(uiStyles) mainView := layout.NewVBox(layout.LastChildFixedAt(17), dtv, div) itemEdit := dynamoitemedit.NewModel(mainView) - statusAndPrompt := statusandprompt.New(itemEdit, "") - tableSelect := tableselect.New(statusAndPrompt) + statusAndPrompt := statusandprompt.New(itemEdit, "", uiStyles.StatusAndPrompt) + dialogPrompt := dialogprompt.New(statusAndPrompt) + tableSelect := tableselect.New(dialogPrompt, uiStyles) cc.AddCommands(&commandctrl.CommandContext{ Commands: map[string]commandctrl.Command{ @@ -43,8 +50,45 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon return rc.ScanTable(args[0]) } }, + "export": func(args []string) tea.Cmd { + if len(args) == 0 { + return events.SetError(errors.New("expected filename")) + } + return rc.ExportCSV(args[0]) + }, "unmark": commandctrl.NoArgCommand(rc.Unmark()), "delete": commandctrl.NoArgCommand(wc.DeleteMarked()), + + // TEMP + "new-item": commandctrl.NoArgCommand(wc.NewItem()), + "set-s": func(args []string) tea.Cmd { + if len(args) == 0 { + return events.SetError(errors.New("expected field")) + } + return wc.SetStringValue(dtv.SelectedItemIndex(), args[0]) + }, + "set-n": func(args []string) tea.Cmd { + if len(args) == 0 { + return events.SetError(errors.New("expected field")) + } + return wc.SetNumberValue(dtv.SelectedItemIndex(), args[0]) + }, + "del-attr": func(args []string) tea.Cmd { + if len(args) == 0 { + return events.SetError(errors.New("expected field")) + } + return wc.DeleteAttribute(dtv.SelectedItemIndex(), args[0]) + }, + + "put": func(args []string) tea.Cmd { + return wc.PutItem(dtv.SelectedItemIndex()) + }, + "touch": func(args []string) tea.Cmd { + return wc.TouchItem(dtv.SelectedItemIndex()) + }, + "noisy-touch": func(args []string) tea.Cmd { + return wc.NoisyTouchItem(dtv.SelectedItemIndex()) + }, }, }) @@ -77,8 +121,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if idx := m.tableView.SelectedItemIndex(); idx >= 0 { return m, m.tableWriteController.ToggleMark(idx) } - case "s": + case "R": return m, m.tableReadController.Rescan() + case "?": + return m, m.tableReadController.PromptForQuery() case "/": return m, m.tableReadController.Filter() case "e": diff --git a/internal/dynamo-browse/ui/teamodels/dialogprompt/dialogmodel.go b/internal/dynamo-browse/ui/teamodels/dialogprompt/dialogmodel.go new file mode 100644 index 0000000..326e9a9 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/dialogprompt/dialogmodel.go @@ -0,0 +1,33 @@ +package dialogprompt + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" +) + +var style = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("63")) + +type dialogModel struct { + w, h int + borderStyle lipgloss.Style +} + +func (d *dialogModel) Init() tea.Cmd { + return nil +} + +func (d *dialogModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return d, nil +} + +func (d *dialogModel) View() string { + return style.Width(d.w).Height(d.h).Render("Hello this is a test of some content") +} + +func (d *dialogModel) Resize(w, h int) layout.ResizingModel { + d.w, d.h = w-2, h-2 + return d +} diff --git a/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go b/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go new file mode 100644 index 0000000..c29b63c --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go @@ -0,0 +1,38 @@ +package dialogprompt + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" +) + +type Model struct { + compositor *layout.Compositor +} + +func New(model layout.ResizingModel) *Model { + m := &Model{ + compositor: layout.NewCompositor(model), + } + // TEMP + //m.compositor.SetOverlay(&dialogModel{}, 5, 5, 30, 12) + return m +} + +func (m *Model) Init() tea.Cmd { + return m.compositor.Init() +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + newModel, cmd := m.compositor.Update(msg) + m.compositor = newModel.(*layout.Compositor) + return m, cmd +} + +func (m *Model) View() string { + return m.compositor.View() +} + +func (m *Model) Resize(w, h int) layout.ResizingModel { + m.compositor = m.compositor.Resize(w, h).(*layout.Compositor) + return m +} diff --git a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go index 9d874dc..f921656 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go @@ -2,10 +2,12 @@ package dynamoitemview import ( "fmt" + "github.com/lmika/awstools/internal/dynamo-browse/models/itemrender" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" + "io" "strings" "text/tabwriter" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -16,9 +18,14 @@ import ( var ( activeHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#4479ff")) + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#4479ff")) + + fieldTypeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#2B800C", Dark: "#73C653"}) + metaInfoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")) ) type Model struct { @@ -32,9 +39,9 @@ type Model struct { selectedItem models.Item } -func New() *Model { +func New(uiStyles styles.Styles) *Model { return &Model{ - frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle), + frameTitle: frame.NewFrameTitle("Item", false, uiStyles.Frames), viewport: viewport.New(100, 100), } } @@ -82,16 +89,19 @@ func (m *Model) updateViewportToSelectedMessage() { viewportContent := &strings.Builder{} tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0) - for _, colName := range m.currentResultSet.Columns { - switch colVal := m.selectedItem[colName].(type) { - case nil: - break - case *types.AttributeValueMemberS: - fmt.Fprintf(tabWriter, "%v\tS\t%s\n", colName, colVal.Value) - case *types.AttributeValueMemberN: - fmt.Fprintf(tabWriter, "%v\tN\t%s\n", colName, colVal.Value) - default: - fmt.Fprintf(tabWriter, "%v\t?\t%s\n", colName, "(other)") + + seenColumns := make(map[string]struct{}) + for _, colName := range m.currentResultSet.Columns() { + seenColumns[colName] = struct{}{} + if r := m.selectedItem.Renderer(colName); r != nil { + m.renderItem(tabWriter, "", colName, r) + } + } + for k, _ := range m.selectedItem { + if _, seen := seenColumns[k]; !seen { + if r := m.selectedItem.Renderer(k); r != nil { + m.renderItem(tabWriter, "", k, r) + } } } @@ -100,3 +110,13 @@ func (m *Model) updateViewportToSelectedMessage() { m.viewport.Height = m.h - m.frameTitle.HeaderHeight() m.viewport.SetContent(viewportContent.String()) } + +func (m *Model) renderItem(w io.Writer, prefix string, name string, r itemrender.Renderer) { + fmt.Fprintf(w, "%s%v\t%s\t%s%s\n", + prefix, name, fieldTypeStyle.Render(r.TypeName()), r.StringValue(), metaInfoStyle.Render(r.MetaInfo())) + if subitems := r.SubItems(); len(subitems) > 0 { + for _, si := range subitems { + m.renderItem(w, prefix+" ", si.Key, si.Value) + } + } +} diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 2bf3f6f..b86fa81 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -1,7 +1,7 @@ package dynamotableview import ( - table "github.com/calyptia/go-bubble-table" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/dynamo-browse/controllers" @@ -9,6 +9,8 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" + table "github.com/lmika/go-bubble-table" ) var ( @@ -18,26 +20,61 @@ var ( Background(lipgloss.Color("#4479ff")) ) +type KeyBinding struct { + MoveUp key.Binding + MoveDown key.Binding + PageUp key.Binding + PageDown key.Binding + Home key.Binding + End key.Binding + ColLeft key.Binding + ColRight key.Binding +} + type Model struct { frameTitle frame.FrameTitle table table.Model w, h int + keyBinding KeyBinding // model state + colOffset int rows []table.Row resultSet *models.ResultSet } -func New() *Model { - tbl := table.New([]string{"pk", "sk"}, 100, 100) +type columnModel struct { + m *Model +} + +func (cm columnModel) Len() int { + return len(cm.m.resultSet.Columns()[cm.m.colOffset:]) +} + +func (cm columnModel) Header(index int) string { + return cm.m.resultSet.Columns()[cm.m.colOffset+index] +} + +func New(uiStyles styles.Styles) *Model { + tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100) rows := make([]table.Row, 0) tbl.SetRows(rows) - frameTitle := frame.NewFrameTitle("No table", true, activeHeaderStyle) + frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames) return &Model{ frameTitle: frameTitle, table: tbl, + keyBinding: KeyBinding{ + MoveUp: key.NewBinding(key.WithKeys("i", "up")), + MoveDown: key.NewBinding(key.WithKeys("k", "down")), + PageUp: key.NewBinding(key.WithKeys("I", "pgup")), + PageDown: key.NewBinding(key.WithKeys("K", "pgdown")), + Home: key.NewBinding(key.WithKeys("I", "home")), + End: key.NewBinding(key.WithKeys("K", "end")), + ColLeft: key.NewBinding(key.WithKeys("j", "left")), + ColRight: key.NewBinding(key.WithKeys("l", "right")), + }, } } @@ -52,26 +89,49 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateTable() return m, m.postSelectedItemChanged case tea.KeyMsg: - switch msg.String() { + switch { // Table nav - case "i", "up": + case key.Matches(msg, m.keyBinding.MoveUp): m.table.GoUp() return m, m.postSelectedItemChanged - case "k", "down": + case key.Matches(msg, m.keyBinding.MoveDown): m.table.GoDown() return m, m.postSelectedItemChanged - case "I", "pgup": + case key.Matches(msg, m.keyBinding.PageUp): m.table.GoPageUp() return m, m.postSelectedItemChanged - case "K", "pgdn": + case key.Matches(msg, m.keyBinding.PageDown): m.table.GoPageDown() return m, m.postSelectedItemChanged + case key.Matches(msg, m.keyBinding.Home): + m.table.GoTop() + return m, m.postSelectedItemChanged + case key.Matches(msg, m.keyBinding.End): + m.table.GoBottom() + return m, m.postSelectedItemChanged + case key.Matches(msg, m.keyBinding.ColLeft): + m.setLeftmostDisplayedColumn(m.colOffset - 1) + return m, nil + case key.Matches(msg, m.keyBinding.ColRight): + m.setLeftmostDisplayedColumn(m.colOffset + 1) + return m, nil } } return m, nil } +func (m *Model) setLeftmostDisplayedColumn(newCol int) { + if newCol < 0 { + m.colOffset = 0 + } else if newCol >= len(m.resultSet.Columns()) { + m.colOffset = len(m.resultSet.Columns()) - 1 + } else { + m.colOffset = newCol + } + m.table.UpdateView() +} + func (m *Model) View() string { return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View()) } @@ -85,23 +145,32 @@ func (m *Model) Resize(w, h int) layout.ResizingModel { } func (m *Model) updateTable() { + m.colOffset = 0 + + m.frameTitle.SetTitle("Table: " + m.resultSet.TableInfo.Name) + m.rebuildTable() +} + +func (m *Model) rebuildTable() { resultSet := m.resultSet - m.frameTitle.SetTitle("Table: " + resultSet.TableInfo.Name) - - newTbl := table.New(resultSet.Columns, m.w, m.h-m.frameTitle.HeaderHeight()) + newTbl := table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight()) newRows := make([]table.Row, 0) for i, r := range resultSet.Items() { if resultSet.Hidden(i) { continue } - newRows = append(newRows, itemTableRow{resultSet: resultSet, itemIndex: i, item: r}) + newRows = append(newRows, itemTableRow{ + model: m, + resultSet: resultSet, + itemIndex: i, + item: r, + }) } m.rows = newRows newTbl.SetRows(newRows) - m.table = newTbl } @@ -135,5 +204,6 @@ func (m *Model) postSelectedItemChanged() tea.Msg { } func (m *Model) Refresh() { + m.table.SetRows(m.rows) } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go index 5e5a08d..0bbd24d 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go @@ -6,17 +6,24 @@ import ( "io" "strings" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - table "github.com/calyptia/go-bubble-table" "github.com/lmika/awstools/internal/dynamo-browse/models" + table "github.com/lmika/go-bubble-table" ) var ( markedRowStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#e1e1e1")) + Background(lipgloss.AdaptiveColor{Light: "#e1e1e1", Dark: "#414141"}) + dirtyRowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#e13131")) + newRowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#31e131")) + + metaInfoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")) ) type itemTableRow struct { + model *Model resultSet *models.ResultSet itemIndex int item models.Item @@ -24,33 +31,39 @@ type itemTableRow struct { func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { isMarked := mtr.resultSet.Marked(mtr.itemIndex) + isDirty := mtr.resultSet.IsDirty(mtr.itemIndex) + isNew := mtr.resultSet.IsNew(mtr.itemIndex) + + var style lipgloss.Style + + if index == model.Cursor() { + style = model.Styles.SelectedRow + } + if isMarked { + style = style.Copy().Inherit(markedRowStyle) + } + if isNew { + style = style.Copy().Inherit(newRowStyle) + } else if isDirty { + style = style.Copy().Inherit(dirtyRowStyle) + } + metaInfoStyle := style.Copy().Inherit(metaInfoStyle) sb := strings.Builder{} - for i, colName := range mtr.resultSet.Columns { + for i, colName := range mtr.resultSet.Columns()[mtr.model.colOffset:] { if i > 0 { - sb.WriteString("\t") + sb.WriteString(style.Render("\t")) } - switch colVal := mtr.item[colName].(type) { - case nil: - sb.WriteString("(nil)") - case *types.AttributeValueMemberS: - sb.WriteString(colVal.Value) - case *types.AttributeValueMemberN: - sb.WriteString(colVal.Value) - default: - sb.WriteString("(other)") + if r := mtr.item.Renderer(colName); r != nil { + sb.WriteString(style.Render(r.StringValue())) + if mi := r.MetaInfo(); mi != "" { + sb.WriteString(metaInfoStyle.Render(mi)) + } + } else { + sb.WriteString(metaInfoStyle.Render("~")) } } - if index == model.Cursor() { - style := model.Styles.SelectedRow - if isMarked { - style = style.Copy().Inherit(markedRowStyle) - } - fmt.Fprintln(w, style.Render(sb.String())) - } else if isMarked { - fmt.Fprintln(w, markedRowStyle.Render(sb.String())) - } else { - fmt.Fprintln(w, sb.String()) - } + + fmt.Fprintln(w, sb.String()) } diff --git a/internal/dynamo-browse/ui/teamodels/frame/frame.go b/internal/dynamo-browse/ui/teamodels/frame/frame.go index 7ce9ba6..e2da798 100644 --- a/internal/dynamo-browse/ui/teamodels/frame/frame.go +++ b/internal/dynamo-browse/ui/teamodels/frame/frame.go @@ -15,14 +15,19 @@ var ( // Frame is a frame that appears in the type FrameTitle struct { - header string - active bool - activeStyle lipgloss.Style - width int + header string + active bool + style Style + width int } -func NewFrameTitle(header string, active bool, activeStyle lipgloss.Style) FrameTitle { - return FrameTitle{header, active, activeStyle, 0} +type Style struct { + ActiveTitle lipgloss.Style + InactiveTitle lipgloss.Style +} + +func NewFrameTitle(header string, active bool, style Style) FrameTitle { + return FrameTitle{header, active, style, 0} } func (f *FrameTitle) SetTitle(title string) { @@ -42,9 +47,9 @@ func (f FrameTitle) HeaderHeight() int { } func (f FrameTitle) headerView() string { - style := inactiveHeaderStyle + style := f.style.InactiveTitle if f.active { - style = f.activeStyle + style = f.style.ActiveTitle } titleText := f.header diff --git a/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go b/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go new file mode 100644 index 0000000..3d31b7b --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go @@ -0,0 +1,37 @@ +package itemdisplay + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils" +) + +type Model struct { + baseMode tea.Model +} + +func New(baseMode tea.Model) *Model { + return &Model{ + baseMode: baseMode, + } +} + +func (m *Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cc utils.CmdCollector + + m.baseMode = cc.Collect(m.baseMode.Update(msg)) + return m, cc.Cmd() +} + +func (m *Model) View() string { + return m.baseMode.View() +} + +func (m *Model) Resize(w, h int) layout.ResizingModel { + m.baseMode = layout.Resize(m.baseMode, w, h) + return m +} diff --git a/internal/dynamo-browse/ui/teamodels/layout/composit.go b/internal/dynamo-browse/ui/teamodels/layout/composit.go new file mode 100644 index 0000000..055521f --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/layout/composit.go @@ -0,0 +1,85 @@ +package layout + +import ( + "bufio" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "strings" +) + +type Compositor struct { + background ResizingModel + + foreground ResizingModel + foreX, foreY int + foreW, foreH int +} + +func NewCompositor(background ResizingModel) *Compositor { + return &Compositor{ + background: background, + } +} + +func (c *Compositor) Init() tea.Cmd { + return c.background.Init() +} + +func (c *Compositor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // TODO: allow the compositor the + newM, cmd := c.background.Update(msg) + c.background = newM.(ResizingModel) + return c, cmd +} + +func (c *Compositor) SetOverlay(m ResizingModel, x, y, w, h int) { + c.foreground = m + c.foreX, c.foreY = x, y + c.foreW, c.foreH = w, h +} + +func (c *Compositor) View() string { + if c.foreground == nil { + return c.background.View() + } + + // Need to compose + backgroundView := c.background.View() + foregroundViewLines := strings.Split(c.foreground.View(), "\n") + + backgroundScanner := bufio.NewScanner(strings.NewReader(backgroundView)) + compositeOutput := new(strings.Builder) + + r := 0 + for backgroundScanner.Scan() { + if r > 0 { + compositeOutput.WriteRune('\n') + } + + line := backgroundScanner.Text() + if r >= c.foreY && r < c.foreY+c.foreH { + compositeOutput.WriteString(line[:c.foreX]) + + foregroundScanPos := r - c.foreY + if foregroundScanPos < len(foregroundViewLines) { + displayLine := foregroundViewLines[foregroundScanPos] + compositeOutput.WriteString(lipgloss.PlaceHorizontal(c.foreW, lipgloss.Left, displayLine, lipgloss.WithWhitespaceChars(" "))) + } + + compositeOutput.WriteString(line[c.foreX+c.foreW:]) + } else { + compositeOutput.WriteString(line) + } + r++ + } + + return compositeOutput.String() +} + +func (c *Compositor) Resize(w, h int) ResizingModel { + c.background = c.background.Resize(w, h) + if c.foreground != nil { + c.foreground = c.foreground.Resize(c.foreW, c.foreH) + } + return c +} diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go index d9ee1dd..a290750 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go @@ -12,15 +12,21 @@ import ( // event is received, focus will be torn away and the user will be given a prompt the enter text. type StatusAndPrompt struct { model layout.ResizingModel + style Style + modeLine string statusMessage string pendingInput *events.PromptForInputMsg textInput textinput.Model width int } -func New(model layout.ResizingModel, initialMsg string) *StatusAndPrompt { +type Style struct { + ModeLine lipgloss.Style +} + +func New(model layout.ResizingModel, initialMsg string, style Style) *StatusAndPrompt { textInput := textinput.New() - return &StatusAndPrompt{model: model, statusMessage: initialMsg, textInput: textInput} + return &StatusAndPrompt{model: model, style: style, statusMessage: initialMsg, modeLine: "", textInput: textInput} } func (s *StatusAndPrompt) Init() tea.Cmd { @@ -33,7 +39,12 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.statusMessage = "Error: " + msg.Error() case events.StatusMsg: s.statusMessage = string(msg) + case events.ModeMessage: + s.modeLine = string(msg) case events.MessageWithStatus: + if hasModeMessage, ok := msg.(events.MessageWithMode); ok { + s.modeLine = hasModeMessage.ModeMessage() + } s.statusMessage = msg.StatusMessage() case events.PromptForInputMsg: if s.pendingInput != nil { @@ -61,6 +72,8 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.textInput = newTextInput return s, cmd } + } else { + s.statusMessage = "" } } @@ -85,8 +98,14 @@ func (s *StatusAndPrompt) Resize(w, h int) layout.ResizingModel { } func (s *StatusAndPrompt) viewStatus() string { + modeLine := s.style.ModeLine.Render(lipgloss.PlaceHorizontal(s.width, lipgloss.Left, s.modeLine, lipgloss.WithWhitespaceChars(" "))) + + var statusLine string if s.pendingInput != nil { - return s.textInput.View() + statusLine = s.textInput.View() + } else { + statusLine = s.statusMessage } - return s.statusMessage + + return lipgloss.JoinVertical(lipgloss.Top, modeLine, statusLine) } diff --git a/internal/dynamo-browse/ui/teamodels/styles/styles.go b/internal/dynamo-browse/ui/teamodels/styles/styles.go new file mode 100644 index 0000000..10caf45 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/styles/styles.go @@ -0,0 +1,29 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" +) + +type Styles struct { + Frames frame.Style + StatusAndPrompt statusandprompt.Style +} + +var DefaultStyles = Styles{ + Frames: frame.Style{ + ActiveTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#4479ff")), + InactiveTitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, + StatusAndPrompt: statusandprompt.Style{ + ModeLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, +} diff --git a/internal/dynamo-browse/ui/teamodels/tableselect/list.go b/internal/dynamo-browse/ui/teamodels/tableselect/list.go index d5adbba..a8acad0 100644 --- a/internal/dynamo-browse/ui/teamodels/tableselect/list.go +++ b/internal/dynamo-browse/ui/teamodels/tableselect/list.go @@ -1,6 +1,7 @@ package tableselect import ( + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -32,6 +33,22 @@ func newListController(tableNames []string, w, h int) listController { Padding(0, 0, 0, 1) list := list.New(items, delegate, w, h) + list.KeyMap.CursorUp = key.NewBinding( + key.WithKeys("up", "i"), + key.WithHelp("↑/i", "up"), + ) + list.KeyMap.CursorDown = key.NewBinding( + key.WithKeys("down", "k"), + key.WithHelp("↓/k", "down"), + ) + list.KeyMap.PrevPage = key.NewBinding( + key.WithKeys("left", "j", "pgup", "b", "u"), + key.WithHelp("←/j/pgup", "prev page"), + ) + list.KeyMap.NextPage = key.NewBinding( + key.WithKeys("right", "l", "pgdown", "f", "d"), + key.WithHelp("→/l/pgdn", "next page"), + ) list.SetShowTitle(false) return listController{list: list} diff --git a/internal/dynamo-browse/ui/teamodels/tableselect/model.go b/internal/dynamo-browse/ui/teamodels/tableselect/model.go index 1feebe7..8f06a97 100644 --- a/internal/dynamo-browse/ui/teamodels/tableselect/model.go +++ b/internal/dynamo-browse/ui/teamodels/tableselect/model.go @@ -7,6 +7,7 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils" ) @@ -26,8 +27,8 @@ type Model struct { w, h int } -func New(submodel tea.Model) *Model { - frameTitle := frame.NewFrameTitle("Select table", false, activeHeaderStyle) +func New(submodel tea.Model, uiStyles styles.Styles) *Model { + frameTitle := frame.NewFrameTitle("Select table", false, uiStyles.Frames) return &Model{frameTitle: frameTitle, submodel: submodel} } diff --git a/internal/slog-view/styles/styles.go b/internal/slog-view/styles/styles.go new file mode 100644 index 0000000..9eabde2 --- /dev/null +++ b/internal/slog-view/styles/styles.go @@ -0,0 +1,29 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" +) + +type Styles struct { + Frames frame.Style + StatusAndPrompt statusandprompt.Style +} + +var DefaultStyles = Styles{ + Frames: frame.Style{ + ActiveTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + InactiveTitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, + StatusAndPrompt: statusandprompt.Style{ + ModeLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, +} diff --git a/internal/slog-view/ui/fullviewlinedetails/model.go b/internal/slog-view/ui/fullviewlinedetails/model.go index 6b0aca5..906db1b 100644 --- a/internal/slog-view/ui/fullviewlinedetails/model.go +++ b/internal/slog-view/ui/fullviewlinedetails/model.go @@ -2,22 +2,23 @@ package fullviewlinedetails import ( tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/slog-view/models" "github.com/lmika/awstools/internal/slog-view/ui/linedetails" ) type Model struct { - submodel tea.Model + submodel tea.Model lineDetails *linedetails.Model visible bool } -func NewModel(submodel tea.Model) *Model { +func NewModel(submodel tea.Model, style frame.Style) *Model { return &Model{ - submodel: submodel, - lineDetails: linedetails.New(), + submodel: submodel, + lineDetails: linedetails.New(style), } } @@ -49,6 +50,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) ViewItem(item *models.LogLine) { m.visible = true m.lineDetails.SetSelectedItem(item) + m.lineDetails.SetFocused(true) } func (m *Model) View() string { diff --git a/internal/slog-view/ui/linedetails/model.go b/internal/slog-view/ui/linedetails/model.go index 8378e03..b5712f5 100644 --- a/internal/slog-view/ui/linedetails/model.go +++ b/internal/slog-view/ui/linedetails/model.go @@ -10,13 +10,6 @@ import ( "github.com/lmika/awstools/internal/slog-view/models" ) -var ( - activeHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#9c9c9c")) -) - type Model struct { frameTitle frame.FrameTitle viewport viewport.Model @@ -27,11 +20,11 @@ type Model struct { selectedItem *models.LogLine } -func New() *Model { +func New(style frame.Style) *Model { viewport := viewport.New(0, 0) viewport.SetContent("") return &Model{ - frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle), + frameTitle: frame.NewFrameTitle("Item", false, style), viewport: viewport, } } diff --git a/internal/slog-view/ui/loglines/model.go b/internal/slog-view/ui/loglines/model.go index dd878aa..65287ed 100644 --- a/internal/slog-view/ui/loglines/model.go +++ b/internal/slog-view/ui/loglines/model.go @@ -1,22 +1,15 @@ package loglines import ( - table "github.com/calyptia/go-bubble-table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/slog-view/models" + table "github.com/lmika/go-bubble-table" "path/filepath" ) -var ( - activeHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#9c9c9c")) -) - type Model struct { frameTitle frame.FrameTitle table table.Model @@ -26,9 +19,9 @@ type Model struct { w, h int } -func New() *Model { - frameTitle := frame.NewFrameTitle("File: ", true, activeHeaderStyle) - table := table.New([]string{"level", "error", "message"}, 0, 0) +func New(style frame.Style) *Model { + frameTitle := frame.NewFrameTitle("File: ", true, style) + table := table.New(table.SimpleColumns{"level", "error", "message"}, 0, 0) return &Model{ frameTitle: frameTitle, @@ -40,7 +33,7 @@ func (m *Model) SetLogFile(newLogFile *models.LogFile) { m.logFile = newLogFile m.frameTitle.SetTitle("File: " + filepath.Base(newLogFile.Filename)) - cols := []string{"level", "error", "message"} + cols := table.SimpleColumns{"level", "error", "message"} newTbl := table.New(cols, m.w, m.h-m.frameTitle.HeaderHeight()) newRows := make([]table.Row, len(newLogFile.Lines)) diff --git a/internal/slog-view/ui/loglines/tblmodel.go b/internal/slog-view/ui/loglines/tblmodel.go index 6adc5d8..2cd53ee 100644 --- a/internal/slog-view/ui/loglines/tblmodel.go +++ b/internal/slog-view/ui/loglines/tblmodel.go @@ -2,7 +2,7 @@ package loglines import ( "fmt" - table "github.com/calyptia/go-bubble-table" + table "github.com/lmika/go-bubble-table" "github.com/lmika/awstools/internal/slog-view/models" "io" "strings" diff --git a/internal/slog-view/ui/model.go b/internal/slog-view/ui/model.go index 41f5aea..4ef7c52 100644 --- a/internal/slog-view/ui/model.go +++ b/internal/slog-view/ui/model.go @@ -6,38 +6,40 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" "github.com/lmika/awstools/internal/slog-view/controllers" + "github.com/lmika/awstools/internal/slog-view/styles" "github.com/lmika/awstools/internal/slog-view/ui/fullviewlinedetails" "github.com/lmika/awstools/internal/slog-view/ui/linedetails" "github.com/lmika/awstools/internal/slog-view/ui/loglines" ) type Model struct { - controller *controllers.LogFileController - cmdController *commandctrl.CommandController + controller *controllers.LogFileController + cmdController *commandctrl.CommandController - root tea.Model - logLines *loglines.Model - lineDetails *linedetails.Model - statusAndPrompt *statusandprompt.StatusAndPrompt + root tea.Model + logLines *loglines.Model + lineDetails *linedetails.Model + statusAndPrompt *statusandprompt.StatusAndPrompt fullViewLineDetails *fullviewlinedetails.Model } func NewModel(controller *controllers.LogFileController, cmdController *commandctrl.CommandController) Model { - logLines := loglines.New() - lineDetails := linedetails.New() + defaultStyles := styles.DefaultStyles + logLines := loglines.New(defaultStyles.Frames) + lineDetails := linedetails.New(defaultStyles.Frames) box := layout.NewVBox(layout.LastChildFixedAt(17), logLines, lineDetails) - fullViewLineDetails := fullviewlinedetails.NewModel(box) - statusAndPrompt := statusandprompt.New(fullViewLineDetails, "") + fullViewLineDetails := fullviewlinedetails.NewModel(box, defaultStyles.Frames) + statusAndPrompt := statusandprompt.New(fullViewLineDetails, "", defaultStyles.StatusAndPrompt) root := layout.FullScreen(statusAndPrompt) return Model{ - controller: controller, - cmdController: cmdController, - root: root, - statusAndPrompt: statusAndPrompt, - logLines: logLines, - lineDetails: lineDetails, + controller: controller, + cmdController: cmdController, + root: root, + statusAndPrompt: statusAndPrompt, + logLines: logLines, + lineDetails: lineDetails, fullViewLineDetails: fullViewLineDetails, } } diff --git a/internal/sqs-browse/styles/styles.go b/internal/sqs-browse/styles/styles.go new file mode 100644 index 0000000..10caf45 --- /dev/null +++ b/internal/sqs-browse/styles/styles.go @@ -0,0 +1,29 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" +) + +type Styles struct { + Frames frame.Style + StatusAndPrompt statusandprompt.Style +} + +var DefaultStyles = Styles{ + Frames: frame.Style{ + ActiveTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#4479ff")), + InactiveTitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, + StatusAndPrompt: statusandprompt.Style{ + ModeLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, +} diff --git a/internal/sqs-browse/ui/model.go b/internal/sqs-browse/ui/model.go index 3063cab..4e1657a 100644 --- a/internal/sqs-browse/ui/model.go +++ b/internal/sqs-browse/ui/model.go @@ -7,7 +7,6 @@ import ( "log" "strings" - table "github.com/calyptia/go-bubble-table" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -16,6 +15,7 @@ import ( "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/sqs-browse/controllers" "github.com/lmika/awstools/internal/sqs-browse/models" + table "github.com/lmika/go-bubble-table" ) var ( @@ -45,7 +45,7 @@ type uiModel struct { } func NewModel(dispatcher *dispatcher.Dispatcher, msgSendingHandlers *controllers.MessageSendingController) tea.Model { - tbl := table.New([]string{"seq", "message"}, 100, 20) + tbl := table.New(table.SimpleColumns{"seq", "message"}, 100, 20) rows := make([]table.Row, 0) tbl.SetRows(rows) diff --git a/internal/sqs-browse/ui/tblmodel.go b/internal/sqs-browse/ui/tblmodel.go index b4fc15c..0eff075 100644 --- a/internal/sqs-browse/ui/tblmodel.go +++ b/internal/sqs-browse/ui/tblmodel.go @@ -5,7 +5,7 @@ import ( "io" "strings" - table "github.com/calyptia/go-bubble-table" + table "github.com/lmika/go-bubble-table" "github.com/lmika/awstools/internal/sqs-browse/models" ) diff --git a/internal/ssm-browse/controllers/ssmcontroller.go b/internal/ssm-browse/controllers/ssmcontroller.go index 2ee83b6..5f2a6b7 100644 --- a/internal/ssm-browse/controllers/ssmcontroller.go +++ b/internal/ssm-browse/controllers/ssmcontroller.go @@ -4,6 +4,7 @@ import ( "context" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/ssm-browse/models" "github.com/lmika/awstools/internal/ssm-browse/services/ssmparameters" "sync" ) @@ -12,15 +13,15 @@ type SSMController struct { service *ssmparameters.Service // state - mutex *sync.Mutex + mutex *sync.Mutex prefix string } func New(service *ssmparameters.Service) *SSMController { return &SSMController{ service: service, - prefix: "/", - mutex: new(sync.Mutex), + prefix: "/", + mutex: new(sync.Mutex), } } @@ -32,7 +33,7 @@ func (c *SSMController) Fetch() tea.Cmd { } return NewParameterListMsg{ - Prefix: c.prefix, + Prefix: c.prefix, Parameters: res, } } @@ -50,8 +51,50 @@ func (c *SSMController) ChangePrefix(newPrefix string) tea.Cmd { c.prefix = newPrefix return NewParameterListMsg{ - Prefix: c.prefix, + Prefix: c.prefix, Parameters: res, } } -} \ No newline at end of file +} + +func (c *SSMController) Clone(param models.SSMParameter) tea.Cmd { + return events.PromptForInput("New key: ", func(value string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + if err := c.service.Clone(ctx, param, value); err != nil { + return events.Error(err) + } + + res, err := c.service.List(context.Background(), c.prefix) + if err != nil { + return events.Error(err) + } + + return NewParameterListMsg{ + Prefix: c.prefix, + Parameters: res, + } + } + }) +} + +func (c *SSMController) DeleteParameter(param models.SSMParameter) tea.Cmd { + return events.Confirm("delete parameter? ", func() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + if err := c.service.Delete(ctx, param); err != nil { + return events.Error(err) + } + + res, err := c.service.List(context.Background(), c.prefix) + if err != nil { + return events.Error(err) + } + + return NewParameterListMsg{ + Prefix: c.prefix, + Parameters: res, + } + } + }) +} diff --git a/internal/ssm-browse/models/models.go b/internal/ssm-browse/models/models.go index 777e6c8..74a9b7d 100644 --- a/internal/ssm-browse/models/models.go +++ b/internal/ssm-browse/models/models.go @@ -1,10 +1,13 @@ package models +import "github.com/aws/aws-sdk-go-v2/service/ssm/types" + type SSMParameters struct { Items []SSMParameter } type SSMParameter struct { Name string + Type types.ParameterType Value string } diff --git a/internal/ssm-browse/providers/awsssm/provider.go b/internal/ssm-browse/providers/awsssm/provider.go index 07786de..ab5cd81 100644 --- a/internal/ssm-browse/providers/awsssm/provider.go +++ b/internal/ssm-browse/providers/awsssm/provider.go @@ -4,11 +4,14 @@ import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/lmika/awstools/internal/ssm-browse/models" "github.com/pkg/errors" "log" ) +const defaultKMSKeyIDForSecureStrings = "alias/aws/ssm" + type Provider struct { client *ssm.Client } @@ -23,13 +26,14 @@ func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*mode log.Printf("new prefix: %v", prefix) pager := ssm.NewGetParametersByPathPaginator(p.client, &ssm.GetParametersByPathInput{ - Path: aws.String(prefix), - Recursive: true, + Path: aws.String(prefix), + Recursive: true, WithDecryption: true, }) items := make([]models.SSMParameter, 0) - outer: for pager.HasMorePages() { +outer: + for pager.HasMorePages() { out, err := pager.NextPage(ctx) if err != nil { return nil, errors.Wrap(err, "cannot get parameters from path") @@ -38,6 +42,7 @@ func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*mode for _, p := range out.Parameters { items = append(items, models.SSMParameter{ Name: aws.ToString(p.Name), + Type: p.Type, Value: aws.ToString(p.Value), }) if len(items) >= maxCount { @@ -48,3 +53,32 @@ func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*mode return &models.SSMParameters{Items: items}, nil } + +func (p *Provider) Put(ctx context.Context, param models.SSMParameter, override bool) error { + in := &ssm.PutParameterInput{ + Name: aws.String(param.Name), + Type: param.Type, + Value: aws.String(param.Value), + Overwrite: override, + } + if param.Type == types.ParameterTypeSecureString { + in.KeyId = aws.String(defaultKMSKeyIDForSecureStrings) + } + + _, err := p.client.PutParameter(ctx, in) + if err != nil { + return errors.Wrap(err, "unable to put new SSM parameter") + } + + return nil +} + +func (p *Provider) Delete(ctx context.Context, param models.SSMParameter) error { + _, err := p.client.DeleteParameter(ctx, &ssm.DeleteParameterInput{ + Name: aws.String(param.Name), + }) + if err != nil { + return errors.Wrap(err, "unable to delete SSM parameter") + } + return nil +} diff --git a/internal/ssm-browse/services/ssmparameters/iface.go b/internal/ssm-browse/services/ssmparameters/iface.go index cc23f54..5a1249a 100644 --- a/internal/ssm-browse/services/ssmparameters/iface.go +++ b/internal/ssm-browse/services/ssmparameters/iface.go @@ -7,4 +7,6 @@ import ( type SSMProvider interface { List(ctx context.Context, prefix string, maxCount int) (*models.SSMParameters, error) + Put(ctx context.Context, param models.SSMParameter, override bool) error + Delete(ctx context.Context, param models.SSMParameter) error } diff --git a/internal/ssm-browse/services/ssmparameters/service.go b/internal/ssm-browse/services/ssmparameters/service.go index 7706801..19e8903 100644 --- a/internal/ssm-browse/services/ssmparameters/service.go +++ b/internal/ssm-browse/services/ssmparameters/service.go @@ -17,4 +17,17 @@ func NewService(provider SSMProvider) *Service { func (s *Service) List(ctx context.Context, prefix string) (*models.SSMParameters, error) { return s.provider.List(ctx, prefix, 100) -} \ No newline at end of file +} + +func (s *Service) Clone(ctx context.Context, param models.SSMParameter, newName string) error { + newParam := models.SSMParameter{ + Name: newName, + Type: param.Type, + Value: param.Value, + } + return s.provider.Put(ctx, newParam, false) +} + +func (s *Service) Delete(ctx context.Context, param models.SSMParameter) error { + return s.provider.Delete(ctx, param) +} diff --git a/internal/ssm-browse/styles/styles.go b/internal/ssm-browse/styles/styles.go new file mode 100644 index 0000000..7e94086 --- /dev/null +++ b/internal/ssm-browse/styles/styles.go @@ -0,0 +1,29 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" +) + +type Styles struct { + Frames frame.Style + StatusAndPrompt statusandprompt.Style +} + +var DefaultStyles = Styles{ + Frames: frame.Style{ + ActiveTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#c144ff")), + InactiveTitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, + StatusAndPrompt: statusandprompt.Style{ + ModeLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, +} diff --git a/internal/ssm-browse/ui/model.go b/internal/ssm-browse/ui/model.go index ae625e9..00168f8 100644 --- a/internal/ssm-browse/ui/model.go +++ b/internal/ssm-browse/ui/model.go @@ -3,11 +3,14 @@ package ui import ( tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/commandctrl" + "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" "github.com/lmika/awstools/internal/ssm-browse/controllers" + "github.com/lmika/awstools/internal/ssm-browse/styles" "github.com/lmika/awstools/internal/ssm-browse/ui/ssmdetails" "github.com/lmika/awstools/internal/ssm-browse/ui/ssmlist" + "github.com/pkg/errors" ) type Model struct { @@ -21,11 +24,28 @@ type Model struct { } func NewModel(controller *controllers.SSMController, cmdController *commandctrl.CommandController) Model { - ssmList := ssmlist.New() - ssmdDetails := ssmdetails.New() + defaultStyles := styles.DefaultStyles + ssmList := ssmlist.New(defaultStyles.Frames) + ssmdDetails := ssmdetails.New(defaultStyles.Frames) statusAndPrompt := statusandprompt.New( - layout.NewVBox(layout.LastChildFixedAt(17), ssmList, ssmdDetails), - "") + layout.NewVBox(layout.LastChildFixedAt(17), ssmList, ssmdDetails), "", defaultStyles.StatusAndPrompt) + + cmdController.AddCommands(&commandctrl.CommandContext{ + Commands: map[string]commandctrl.Command{ + "clone": func(args []string) tea.Cmd { + if currentParam := ssmList.CurrentParameter(); currentParam != nil { + return controller.Clone(*currentParam) + } + return events.SetError(errors.New("no parameter selected")) + }, + "delete": func(args []string) tea.Cmd { + if currentParam := ssmList.CurrentParameter(); currentParam != nil { + return controller.DeleteParameter(*currentParam) + } + return events.SetError(errors.New("no parameter selected")) + }, + }, + }) root := layout.FullScreen(statusAndPrompt) diff --git a/internal/ssm-browse/ui/ssmdetails/model.go b/internal/ssm-browse/ui/ssmdetails/model.go index 7c0db0b..b4e5030 100644 --- a/internal/ssm-browse/ui/ssmdetails/model.go +++ b/internal/ssm-browse/ui/ssmdetails/model.go @@ -11,13 +11,6 @@ import ( "strings" ) -var ( - activeHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#c144ff")) -) - type Model struct { frameTitle frame.FrameTitle viewport viewport.Model @@ -28,11 +21,11 @@ type Model struct { selectedItem *models.SSMParameter } -func New() *Model { +func New(style frame.Style) *Model { viewport := viewport.New(0, 0) viewport.SetContent("") return &Model{ - frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle), + frameTitle: frame.NewFrameTitle("Item", false, style), viewport: viewport, } } diff --git a/internal/ssm-browse/ui/ssmlist/ssmlist.go b/internal/ssm-browse/ui/ssmlist/ssmlist.go index 9d90964..1edcabc 100644 --- a/internal/ssm-browse/ui/ssmlist/ssmlist.go +++ b/internal/ssm-browse/ui/ssmlist/ssmlist.go @@ -1,19 +1,12 @@ package ssmlist import ( - table "github.com/calyptia/go-bubble-table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/ssm-browse/models" -) - -var ( - activeHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#c144ff")) + table "github.com/lmika/go-bubble-table" ) type Model struct { @@ -25,9 +18,9 @@ type Model struct { w, h int } -func New() *Model { - frameTitle := frame.NewFrameTitle("SSM: /", true, activeHeaderStyle) - table := table.New([]string{"name", "type", "value"}, 0, 0) +func New(style frame.Style) *Model { + frameTitle := frame.NewFrameTitle("SSM: /", true, style) + table := table.New(table.SimpleColumns{"name", "type", "value"}, 0, 0) return &Model{ frameTitle: frameTitle, @@ -41,7 +34,7 @@ func (m *Model) SetPrefix(newPrefix string) { func (m *Model) SetParameters(parameters *models.SSMParameters) { m.parameters = parameters - cols := []string{"name", "type", "value"} + cols := table.SimpleColumns{"name", "type", "value"} newTbl := table.New(cols, m.w, m.h-m.frameTitle.HeaderHeight()) newRows := make([]table.Row, len(parameters.Items)) @@ -85,6 +78,14 @@ func (m *Model) emitNewSelectedParameter() tea.Cmd { } } +func (m *Model) CurrentParameter() *models.SSMParameter { + if row, ok := m.table.SelectedRow().(itemTableRow); ok { + return &(row.item) + } + + return nil +} + func (m *Model) View() string { return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View()) } diff --git a/internal/ssm-browse/ui/ssmlist/tblmodel.go b/internal/ssm-browse/ui/ssmlist/tblmodel.go index d7c0d7e..df35ccc 100644 --- a/internal/ssm-browse/ui/ssmlist/tblmodel.go +++ b/internal/ssm-browse/ui/ssmlist/tblmodel.go @@ -2,7 +2,7 @@ package ssmlist import ( "fmt" - table "github.com/calyptia/go-bubble-table" + table "github.com/lmika/go-bubble-table" "github.com/lmika/awstools/internal/ssm-browse/models" "io" "strings" @@ -14,7 +14,7 @@ type itemTableRow struct { func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { firstLine := strings.SplitN(mtr.item.Value, "\n", 2)[0] - line := fmt.Sprintf("%s\t%s\t%s", mtr.item.Name, "String", firstLine) + line := fmt.Sprintf("%s\t%s\t%s", mtr.item.Name, mtr.item.Type, firstLine) if index == model.Cursor() { fmt.Fprintln(w, model.Styles.SelectedRow.Render(line)) diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go index 847db77..44efdde 100644 --- a/test/cmd/load-test-table/main.go +++ b/test/cmd/load-test-table/main.go @@ -2,24 +2,24 @@ package main import ( "context" - "github.com/brianvoe/gofakeit/v6" - "github.com/google/uuid" - "log" - + "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/brianvoe/gofakeit/v6" + "github.com/google/uuid" "github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" "github.com/lmika/gopkgs/cli" + "log" ) func main() { ctx := context.Background() - tableName := "awstools-test" - totalItems := 300 + tableName := "business-addresses" + totalItems := 5000 cfg, err := config.LoadDefaultConfig(ctx) if err != nil { @@ -66,13 +66,19 @@ func main() { for i := 0; i < totalItems; i++ { key := uuid.New().String() if err := tableService.Put(ctx, tableInfo, models.Item{ - "pk": &types.AttributeValueMemberS{Value: key}, - "sk": &types.AttributeValueMemberS{Value: key}, - "name": &types.AttributeValueMemberS{Value: gofakeit.Name()}, - "address": &types.AttributeValueMemberS{Value: gofakeit.Address().Address}, - "city": &types.AttributeValueMemberS{Value: gofakeit.Address().City}, - "phone": &types.AttributeValueMemberS{Value: gofakeit.Phone()}, - "web": &types.AttributeValueMemberS{Value: gofakeit.URL()}, + "pk": &types.AttributeValueMemberS{Value: key}, + "sk": &types.AttributeValueMemberS{Value: key}, + "name": &types.AttributeValueMemberS{Value: gofakeit.Name()}, + "address": &types.AttributeValueMemberS{Value: gofakeit.Address().Address}, + "city": &types.AttributeValueMemberS{Value: gofakeit.Address().City}, + "phone": &types.AttributeValueMemberN{Value: gofakeit.Phone()}, + "web": &types.AttributeValueMemberS{Value: gofakeit.URL()}, + "officeOpened": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()}, + "ratings": &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))}, + &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))}, + &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))}, + }}, }); err != nil { log.Fatalln(err) } diff --git a/test/testdynamo/client.go b/test/testdynamo/client.go index bb7be11..bc23d23 100644 --- a/test/testdynamo/client.go +++ b/test/testdynamo/client.go @@ -13,9 +13,12 @@ import ( "github.com/stretchr/testify/assert" ) -type TestData []map[string]interface{} +type TestData struct { + TableName string + Data []map[string]interface{} +} -func SetupTestTable(t *testing.T, tableName string, testData TestData) (*dynamodb.Client, func()) { +func SetupTestTable(t *testing.T, testData []TestData) (*dynamodb.Client, func()) { t.Helper() ctx := context.Background() @@ -25,39 +28,45 @@ func SetupTestTable(t *testing.T, tableName string, testData TestData) (*dynamod assert.NoError(t, err) dynamoClient := dynamodb.NewFromConfig(cfg, - dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:18000"))) - _, err = dynamoClient.CreateTable(ctx, &dynamodb.CreateTableInput{ - TableName: aws.String(tableName), - KeySchema: []types.KeySchemaElement{ - {AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash}, - {AttributeName: aws.String("sk"), KeyType: types.KeyTypeRange}, - }, - AttributeDefinitions: []types.AttributeDefinition{ - {AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS}, - {AttributeName: aws.String("sk"), AttributeType: types.ScalarAttributeTypeS}, - }, - ProvisionedThroughput: &types.ProvisionedThroughput{ - ReadCapacityUnits: aws.Int64(100), - WriteCapacityUnits: aws.Int64(100), - }, + for _, table := range testData { + _, err = dynamoClient.CreateTable(ctx, &dynamodb.CreateTableInput{ + TableName: aws.String(table.TableName), + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String("sk"), KeyType: types.KeyTypeRange}, + }, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String("sk"), AttributeType: types.ScalarAttributeTypeS}, + }, + ProvisionedThroughput: &types.ProvisionedThroughput{ + ReadCapacityUnits: aws.Int64(100), + WriteCapacityUnits: aws.Int64(100), + }, + }) + assert.NoError(t, err) + + for _, item := range table.Data { + m, err := attributevalue.MarshalMap(item) + assert.NoError(t, err) + + _, err = dynamoClient.PutItem(ctx, &dynamodb.PutItemInput{ + TableName: aws.String(table.TableName), + Item: m, + }) + assert.NoError(t, err) + } + } + + t.Cleanup(func() { + for _, table := range testData { + dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ + TableName: aws.String(table.TableName), + }) + } }) - assert.NoError(t, err) - for _, item := range testData { - m, err := attributevalue.MarshalMap(item) - assert.NoError(t, err) - - _, err = dynamoClient.PutItem(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(tableName), - Item: m, - }) - assert.NoError(t, err) - } - - return dynamoClient, func() { - dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ - TableName: aws.String(tableName), - }) - } + return dynamoClient, func() {} }