diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 54bb1b9..7570131 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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..f5a333d --- /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 ./... + 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 f72de9c..f56a40b 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -43,8 +43,9 @@ func main() { tableService := tables.NewService(dynamoProvider) - tableReadController := controllers.NewTableReadController(tableService, *flagTable) - tableWriteController := controllers.NewTableWriteController(tableService, tableReadController) + state := controllers.NewState() + tableReadController := controllers.NewTableReadController(state, tableService, *flagTable) + tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController) commandController := commandctrl.NewCommandController() model := ui.NewModel(tableReadController, tableWriteController, commandController) diff --git a/go.mod b/go.mod index da7b341..f77add4 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0 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 @@ -42,18 +41,20 @@ require ( 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.20220608033210-61eeb29a6239 // 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..c730d98 100644 --- a/go.sum +++ b/go.sum @@ -62,12 +62,19 @@ github.com/brianvoe/gofakeit/v6 v6.15.0 h1:lJPGJZ2/07TRGDazyTzD5b18N3y4tmmJpdhCU 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= @@ -93,6 +100,8 @@ 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/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 +121,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 +132,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 +155,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/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/state.go b/internal/dynamo-browse/controllers/state.go index a711e6d..3518e6b 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -1,28 +1,46 @@ 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) setResultSetAndFilter(resultSet *models.ResultSet, filter string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.resultSet = resultSet + s.filter = filter } diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 23b6d83..07bbea8 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -16,13 +16,15 @@ type TableReadController struct { tableName string // state - mutex *sync.Mutex - resultSet *models.ResultSet - filter string + mutex *sync.Mutex + state *State + //resultSet *models.ResultSet + //filter string } -func NewTableReadController(tableService TableReadService, tableName string) *TableReadController { +func NewTableReadController(state *State, tableService TableReadService, tableName string) *TableReadController { return &TableReadController{ + state: state, tableService: tableService, tableName: tableName, mutex: new(sync.Mutex), @@ -68,19 +70,19 @@ 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) Rescan() tea.Cmd { return func() tea.Msg { - return c.doScan(context.Background(), c.resultSet) + return c.doScan(context.Background(), c.state.ResultSet()) } } func (c *TableReadController) ExportCSV(filename string) tea.Cmd { return func() tea.Msg { - resultSet := c.resultSet + resultSet := c.state.ResultSet() if resultSet == nil { return events.Error(errors.New("no result set")) } @@ -119,39 +121,30 @@ func (c *TableReadController) doScan(ctx context.Context, resultSet *models.Resu 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) + return c.setResultSetAndFilter(newResultSet, c.state.Filter()) } -func (c *TableReadController) ResultSet() *models.ResultSet { - c.mutex.Lock() - defer c.mutex.Unlock() - - return c.resultSet -} +//func (c *TableReadController) ResultSet() *models.ResultSet { +// c.mutex.Lock() +// defer c.mutex.Unlock() +// +// return c.resultSet +//} func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg { - c.mutex.Lock() - defer c.mutex.Unlock() - - c.resultSet = resultSet - c.filter = filter + c.state.setResultSetAndFilter(resultSet, filter) return NewResultSet{resultSet} } 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{} } } @@ -162,7 +155,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 index 006a390..74ad025 100644 --- a/internal/dynamo-browse/controllers/tableread_test.go +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -22,7 +22,7 @@ func TestTableReadController_InitTable(t *testing.T) { service := tables.NewService(provider) t.Run("should prompt for table if no table name provided", func(t *testing.T) { - readController := controllers.NewTableReadController(service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, "") cmd := readController.Init() event := cmd() @@ -31,7 +31,7 @@ func TestTableReadController_InitTable(t *testing.T) { }) t.Run("should scan table if table name provided", func(t *testing.T) { - readController := controllers.NewTableReadController(service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, "") cmd := readController.Init() event := cmd() @@ -46,7 +46,7 @@ func TestTableReadController_ListTables(t *testing.T) { provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, "") t.Run("returns a list of tables", func(t *testing.T) { cmd := readController.ListTables() @@ -70,7 +70,7 @@ func TestTableReadController_ExportCSV(t *testing.T) { provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(service, "alpha-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, "alpha-table") t.Run("should export result set to CSV file", func(t *testing.T) { tempFile := tempFile(t) @@ -91,7 +91,7 @@ func TestTableReadController_ExportCSV(t *testing.T) { t.Run("should return error if result set is not set", func(t *testing.T) { tempFile := tempFile(t) - readController := controllers.NewTableReadController(service, "non-existant-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, "non-existant-table") invokeCommandExpectingError(t, readController.Init()) invokeCommandExpectingError(t, readController.ExportCSV(tempFile)) @@ -114,13 +114,54 @@ func tempFile(t *testing.T) string { return tempFile.Name() } -func invokeCommand(t *testing.T, cmd tea.Cmd) { +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) { diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 4b56b5f..7c90d90 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,150 @@ 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 NewResultSet{twc.state.ResultSet()} + } + + return keyPrompts.next() + } +} + +func (twc *TableWriteController) SetStringValue(idx int, key string) tea.Cmd { + return func() tea.Msg { + return events.PromptForInputMsg{ + Prompt: "string value: ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + twc.state.withResultSet(func(set *models.ResultSet) { + set.Items()[idx][key] = &types.AttributeValueMemberS{Value: value} + set.SetDirty(idx, true) + }) + 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) + } + }, + } + } +} + 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 { diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index a8f5c1a..de08093 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -1,6 +1,11 @@ package controllers_test import ( + "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" ) @@ -173,24 +178,248 @@ func setupController(t *testing.T) (*controllers.TableWriteController, controlle tableService: tableService, }, cleanupFn } - -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_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() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + assert.Len(t, state.ResultSet().Items(), 3) + + // Prompt for keys + invokeCommandWithPrompts(t, writeController.NewItem(), "pk-value", "sk-value") + + newResultSet := state.ResultSet() + assert.Len(t, newResultSet.Items(), 4) + assert.Len(t, newResultSet.Items()[3], 2) + + 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_SetStringValue(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + t.Run("should add a new empty item at the end of the result set", 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 prevent duplicate partition,sort keys", func(t *testing.T) { + t.Skip("TODO") + }) +} + +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 49b16dd..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 @@ -28,3 +31,7 @@ func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue { 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..40e4f31 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -10,6 +10,8 @@ type ResultSet struct { type ItemAttribute struct { Marked bool Hidden bool + Dirty bool + New bool } func (rs *ResultSet) Items() []Item { @@ -21,6 +23,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 +36,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 +52,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 { diff --git a/internal/dynamo-browse/providers/dynamo/provider.go b/internal/dynamo-browse/providers/dynamo/provider.go index 4c74d74..1e46e72 100644 --- a/internal/dynamo-browse/providers/dynamo/provider.go +++ b/internal/dynamo-browse/providers/dynamo/provider.go @@ -64,17 +64,27 @@ 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, maxItems int) ([]models.Item, error) { + paginator := dynamodb.NewScanPaginator(p.client, &dynamodb.ScanInput{ TableName: aws.String(tableName), + Limit: aws.Int32(int32(maxItems)), }) - if err != nil { - return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName) - } - items := make([]models.Item, len(res.Items)) - for i, itm := range res.Items { - items[i] = itm + 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 1540792..5d457b2 100644 --- a/internal/dynamo-browse/providers/dynamo/provider_test.go +++ b/internal/dynamo-browse/providers/dynamo/provider_test.go @@ -20,7 +20,7 @@ func TestProvider_ScanItems(t *testing.T) { 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, 100) assert.NoError(t, err) assert.Len(t, items, 3) @@ -32,7 +32,7 @@ func TestProvider_ScanItems(t *testing.T) { 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", 100) assert.Error(t, err) assert.Nil(t, items) }) @@ -53,7 +53,7 @@ func TestProvider_DeleteItem(t *testing.T) { "sk": &types.AttributeValueMemberS{Value: "222"}, }) - items, err := provider.ScanItems(ctx, tableName) + items, err := provider.ScanItems(ctx, tableName, 100) assert.NoError(t, err) assert.Len(t, items, 2) @@ -75,7 +75,7 @@ func TestProvider_DeleteItem(t *testing.T) { "sk": &types.AttributeValueMemberS{Value: "999"}, }) - items, err := provider.ScanItems(ctx, tableName) + items, err := provider.ScanItems(ctx, tableName, 100) assert.NoError(t, err) assert.Len(t, items, 3) @@ -91,7 +91,7 @@ func TestProvider_DeleteItem(t *testing.T) { ctx := context.Background() - items, err := provider.ScanItems(ctx, "does-not-exist") + items, err := provider.ScanItems(ctx, "does-not-exist", 100) assert.Error(t, err) assert.Nil(t, items) }) diff --git a/internal/dynamo-browse/services/tables/iface.go b/internal/dynamo-browse/services/tables/iface.go index 2dddc6c..8548115 100644 --- a/internal/dynamo-browse/services/tables/iface.go +++ b/internal/dynamo-browse/services/tables/iface.go @@ -10,7 +10,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, 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..f6b56e5 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -28,7 +28,7 @@ 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) + results, err := s.provider.ScanItems(ctx, tableInfo.Name, 1000) if err != nil { return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name) } @@ -81,6 +81,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 { diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index f59642b..2e9d9ba 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -48,6 +48,25 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon }, "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]) + }, + + "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()) + }, }, }) diff --git a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go index 9d874dc..bcc65cf 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go @@ -2,10 +2,11 @@ package dynamoitemview import ( "fmt" + "github.com/lmika/awstools/internal/dynamo-browse/models/itemrender" + "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 +17,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 { @@ -83,15 +89,8 @@ 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)") + if r := m.selectedItem.Renderer(colName); r != nil { + m.renderItem(tabWriter, "", colName, r) } } @@ -100,3 +99,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..971e99d 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,7 @@ 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" + table "github.com/lmika/go-bubble-table" ) var ( @@ -18,18 +19,43 @@ 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 } +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() *Model { - tbl := table.New([]string{"pk", "sk"}, 100, 100) + tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100) rows := make([]table.Row, 0) tbl.SetRows(rows) @@ -38,6 +64,16 @@ func New() *Model { 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 +88,51 @@ 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 + } + // TEMP + m.table.GoDown() + m.table.GoUp() +} + func (m *Model) View() string { return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View()) } @@ -85,23 +146,41 @@ 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) - + /* + for newTbl.Cursor() != m.table.Cursor() { + if newTbl.Cursor() < m.table.Cursor() { + newTbl.GoDown() + } else if newTbl.Cursor() > m.table.Cursor() { + newTbl.GoUp() + } + } + */ m.table = newTbl } @@ -135,5 +214,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..69d599f 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{Dark: "#e1e1e1", Light: "#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,37 @@ 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)) + } } } - 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/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/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/slog-view/ui/loglines/model.go b/internal/slog-view/ui/loglines/model.go index dd878aa..f682919 100644 --- a/internal/slog-view/ui/loglines/model.go +++ b/internal/slog-view/ui/loglines/model.go @@ -1,12 +1,12 @@ 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" ) @@ -28,7 +28,7 @@ type Model struct { func New() *Model { frameTitle := frame.NewFrameTitle("File: ", true, activeHeaderStyle) - table := table.New([]string{"level", "error", "message"}, 0, 0) + table := table.New(table.SimpleColumns{"level", "error", "message"}, 0, 0) return &Model{ frameTitle: frameTitle, @@ -40,7 +40,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/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/ui/ssmlist/ssmlist.go b/internal/ssm-browse/ui/ssmlist/ssmlist.go index e304e66..f909f27 100644 --- a/internal/ssm-browse/ui/ssmlist/ssmlist.go +++ b/internal/ssm-browse/ui/ssmlist/ssmlist.go @@ -1,12 +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" + table "github.com/lmika/go-bubble-table" ) var ( @@ -27,7 +27,7 @@ type Model struct { func New() *Model { frameTitle := frame.NewFrameTitle("SSM: /", true, activeHeaderStyle) - table := table.New([]string{"name", "type", "value"}, 0, 0) + table := table.New(table.SimpleColumns{"name", "type", "value"}, 0, 0) return &Model{ frameTitle: frameTitle, @@ -41,7 +41,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)) diff --git a/internal/ssm-browse/ui/ssmlist/tblmodel.go b/internal/ssm-browse/ui/ssmlist/tblmodel.go index 6a28598..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" diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go index 1db13d7..a09d047 100644 --- a/test/cmd/load-test-table/main.go +++ b/test/cmd/load-test-table/main.go @@ -5,6 +5,7 @@ import ( "github.com/brianvoe/gofakeit/v6" "github.com/google/uuid" "log" + "strconv" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -19,7 +20,7 @@ import ( func main() { ctx := context.Background() tableName := "awstools-test" - totalItems := 300 + totalItems := 5000 cfg, err := config.LoadDefaultConfig(ctx) if err != nil { @@ -27,7 +28,7 @@ func main() { } dynamoClient := dynamodb.NewFromConfig(cfg, - dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:18000"))) if _, err = dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ TableName: aws.String(tableName), @@ -66,13 +67,22 @@ 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()}, + "inOffice": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()}, + "ratings": &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: gofakeit.Adverb()}, + &types.AttributeValueMemberN{Value: "12.34"}, + }}, + "values": &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ + "adverb": &types.AttributeValueMemberS{Value: gofakeit.Adverb()}, + "int": &types.AttributeValueMemberN{Value: strconv.Itoa(int(gofakeit.Int32()))}, + }}, }); err != nil { log.Fatalln(err) }