Merge pull request #2 from lmika/feature/table-select

Added table selection
This commit is contained in:
Leon Mika 2022-03-29 07:50:24 +11:00 committed by GitHub
commit 46be54b5fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1299 additions and 432 deletions

View file

@ -4,7 +4,6 @@ on:
push:
branches:
- main
- feature/*
pull_request:
branches:
- main
@ -24,7 +23,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.17
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"

View file

@ -4,19 +4,18 @@ import (
"context"
"flag"
"fmt"
"os"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/common/ui/commandctrl"
"github.com/lmika/awstools/internal/common/ui/dispatcher"
"github.com/lmika/awstools/internal/common/ui/uimodels"
"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"
"os"
)
func main() {
@ -25,6 +24,7 @@ func main() {
flag.Parse()
ctx := context.Background()
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
cli.Fatalf("cannot load AWS config: %v", err)
@ -42,21 +42,22 @@ func main() {
tableService := tables.NewService(dynamoProvider)
loopback := &msgLoopback{}
uiDispatcher := dispatcher.NewDispatcher(loopback)
tableReadController := controllers.NewTableReadController(tableService, *flagTable)
tableWriteController := controllers.NewTableWriteController(tableService, tableReadController, *flagTable)
_ = tableWriteController
commandController := commandctrl.NewCommandController(map[string]uimodels.Operation{
"scan": tableReadController.Scan(),
"rw": tableWriteController.ToggleReadWrite(),
"dup": tableWriteController.Duplicate(),
commandController := commandctrl.NewCommandController(map[string]commandctrl.Command{
"q": commandctrl.NoArgCommand(tea.Quit),
//"rw": tableWriteController.ToggleReadWrite(),
//"dup": tableWriteController.Duplicate(),
})
uiModel := ui.NewModel(uiDispatcher, commandController, tableReadController, tableWriteController)
p := tea.NewProgram(uiModel, tea.WithAltScreen())
loopback.program = p
model := ui.NewModel(tableReadController, commandController)
// Pre-determine if layout has dark background. This prevents calls for creating a list to hang.
lipgloss.HasDarkBackground()
p := tea.NewProgram(model, tea.WithAltScreen())
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
@ -65,16 +66,18 @@ func main() {
}
defer f.Close()
log.Println("launching")
if err := p.Start(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
}
type msgLoopback struct {
program *tea.Program
}
func (m *msgLoopback) Send(msg tea.Msg) {
m.program.Send(msg)
}
//
//type msgLoopback struct {
// program *tea.Program
//}
//
//func (m *msgLoopback) Send(msg tea.Msg) {
// m.program.Send(msg)
//}

6
docker-compose.yml Normal file
View file

@ -0,0 +1,6 @@
version: '3'
services:
dynamo:
image: amazon/dynamodb-local:latest
ports:
- 8000:8000

13
go.mod
View file

@ -1,27 +1,30 @@
module github.com/lmika/awstools
go 1.17
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.15.0
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/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/lipgloss v0.5.0
github.com/google/uuid v1.3.0
github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e
github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.7.1
)
require (
github.com/alecthomas/participle/v2 v2.0.0-alpha7 // indirect
github.com/asdine/storm v2.1.2+incompatible // indirect
github.com/atotto/clipboard v0.1.4 // 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.6 // indirect
@ -34,13 +37,10 @@ require (
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.1 // indirect
github.com/brianvoe/gofakeit/v6 v6.15.0 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe // 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
@ -50,6 +50,7 @@ require (
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // 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

1
go.sum
View file

@ -116,6 +116,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=

View file

@ -1,49 +1,45 @@
package commandctrl
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"strings"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/common/ui/uimodels"
"github.com/lmika/shellwords"
"github.com/pkg/errors"
)
type CommandController struct {
commands map[string]uimodels.Operation
commands map[string]Command
}
func NewCommandController(commands map[string]uimodels.Operation) *CommandController {
func NewCommandController(commands map[string]Command) *CommandController {
return &CommandController{
commands: commands,
}
}
func (c *CommandController) Prompt() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
uiCtx := uimodels.Ctx(ctx)
uiCtx.Send(events.PromptForInput{
func (c *CommandController) Prompt() tea.Cmd {
return func() tea.Msg {
return events.PromptForInputMsg{
Prompt: ":",
OnDone: c.Execute(),
})
OnDone: func(value string) tea.Cmd {
return c.Execute(value)
},
}
}
}
func (c *CommandController) Execute(commandInput string) tea.Cmd {
input := strings.TrimSpace(commandInput)
if input == "" {
return nil
})
}
func (c *CommandController) Execute() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
input := strings.TrimSpace(uimodels.PromptValue(ctx))
if input == "" {
return nil
}
tokens := shellwords.Split(input)
command, ok := c.commands[tokens[0]]
if !ok {
return errors.New("no such command: " + tokens[0])
}
return command.Execute(WithCommandArgs(ctx, tokens[1:]))
})
}
tokens := shellwords.Split(input)
command, ok := c.commands[tokens[0]]
if !ok {
return events.SetStatus("no such command: " + tokens[0])
}
return command(tokens)
}

View file

@ -1,12 +1,10 @@
package commandctrl_test
import (
"context"
"testing"
"github.com/lmika/awstools/internal/common/ui/commandctrl"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/test/testuictx"
"github.com/stretchr/testify/assert"
)
@ -14,13 +12,10 @@ func TestCommandController_Prompt(t *testing.T) {
t.Run("prompt user for a command", func(t *testing.T) {
cmd := commandctrl.NewCommandController(nil)
ctx, uiCtx := testuictx.New(context.Background())
err := cmd.Prompt().Execute(ctx)
res := cmd.Prompt()()
assert.NoError(t, err)
promptMsg, ok := uiCtx.Messages[0].(events.PromptForInput)
promptForInputMsg, ok := res.(events.PromptForInputMsg)
assert.True(t, ok)
assert.Equal(t, ":", promptMsg.Prompt)
assert.Equal(t, ":", promptForInputMsg.Prompt)
})
}

View file

@ -0,0 +1,11 @@
package commandctrl
import tea "github.com/charmbracelet/bubbletea"
type Command func(args []string) tea.Cmd
func NoArgCommand(cmd tea.Cmd) Command {
return func(args []string) tea.Cmd {
return cmd
}
}

View file

@ -1,10 +1,7 @@
package dispatcher
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/common/ui/uimodels"
)
@ -13,20 +10,20 @@ type DispatcherContext struct {
}
func (dc DispatcherContext) Messagef(format string, args ...interface{}) {
dc.Publisher.Send(events.Message(fmt.Sprintf(format, args...)))
// dc.Publisher.Send(events.Message(fmt.Sprintf(format, args...)))
}
func (dc DispatcherContext) Send(teaMessage tea.Msg) {
dc.Publisher.Send(teaMessage)
// dc.Publisher.Send(teaMessage)
}
func (dc DispatcherContext) Message(msg string) {
dc.Publisher.Send(events.Message(msg))
// dc.Publisher.Send(events.Message(msg))
}
func (dc DispatcherContext) Input(prompt string, onDone uimodels.Operation) {
dc.Publisher.Send(events.PromptForInput{
Prompt: prompt,
OnDone: onDone,
})
// dc.Publisher.Send(events.PromptForInput{
// Prompt: prompt,
// OnDone: onDone,
// })
}

View file

@ -0,0 +1,26 @@
package events
import tea "github.com/charmbracelet/bubbletea"
func Error(err error) tea.Msg {
return ErrorMsg(err)
}
func SetStatus(msg string) tea.Cmd {
return func() tea.Msg {
return StatusMsg(msg)
}
}
func PromptForInput(prompt string, onDone func(value string) tea.Cmd) tea.Cmd {
return func() tea.Msg {
return PromptForInputMsg{
Prompt: prompt,
OnDone: onDone,
}
}
}
type MessageWithStatus interface {
StatusMessage() string
}

View file

@ -1,17 +1,17 @@
package events
import (
"github.com/lmika/awstools/internal/common/ui/uimodels"
tea "github.com/charmbracelet/bubbletea"
)
// Error indicates that an error occurred
type Error error
type ErrorMsg error
// Message indicates that a message should be shown to the user
type Message string
type StatusMsg string
// PromptForInput indicates that the context is requesting a line of input
type PromptForInput struct {
type PromptForInputMsg struct {
Prompt string
OnDone uimodels.Operation
OnDone func(value string) tea.Cmd
}

View file

@ -1,11 +1,25 @@
package controllers
import "github.com/lmika/awstools/internal/dynamo-browse/models"
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/dynamo-browse/models"
)
type NewResultSet struct {
ResultSet *models.ResultSet
}
func (rs NewResultSet) StatusMessage() string {
return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items))
}
type SetReadWrite struct {
NewValue bool
}
type PromptForTableMsg struct {
Tables []string
OnSelected func(tableName string) tea.Cmd
}

View file

@ -2,8 +2,8 @@ package controllers
import (
"context"
"github.com/lmika/awstools/internal/common/ui/uimodels"
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"
@ -21,6 +21,63 @@ func NewTableReadController(tableService *tables.Service, tableName string) *Tab
}
}
// Init does an initial scan of the table. If no table is specified, it prompts for a table, then does a scan.
func (c *TableReadController) Init() tea.Cmd {
if c.tableName == "" {
return c.listTables()
} else {
return c.scanTable(c.tableName)
}
}
func (c *TableReadController) listTables() tea.Cmd {
return func() tea.Msg {
tables, err := c.tableService.ListTables(context.Background())
if err != nil {
return events.Error(err)
}
return PromptForTableMsg{
Tables: tables,
OnSelected: func(tableName string) tea.Cmd {
return c.scanTable(tableName)
},
}
}
}
func (c *TableReadController) scanTable(name string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
tableInfo, err := c.tableService.Describe(ctx, name)
if err != nil {
return events.Error(errors.Wrapf(err, "cannot describe %v", c.tableName))
}
resultSet, err := c.tableService.Scan(ctx, tableInfo)
if err != nil {
return events.Error(err)
}
return NewResultSet{resultSet}
}
}
func (c *TableReadController) Rescan(resultSet *models.ResultSet) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
resultSet, err := c.tableService.Scan(ctx, resultSet.TableInfo)
if err != nil {
return events.Error(err)
}
return NewResultSet{resultSet}
}
}
/*
func (c *TableReadController) Scan() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
return c.doScan(ctx, false)
@ -50,17 +107,20 @@ func (c *TableReadController) doScan(ctx context.Context, quiet bool) (err error
uiCtx.Send(NewResultSet{resultSet})
return nil
}
*/
// tableInfo returns the table info from the state if a result set exists. If not, it fetches the
// table information from the service.
func (c *TableReadController) tableInfo(ctx context.Context) (*models.TableInfo, error) {
if existingResultSet := CurrentState(ctx).ResultSet; existingResultSet != nil {
return existingResultSet.TableInfo, nil
}
// func (c *TableReadController) tableInfo(ctx context.Context) (*models.TableInfo, error) {
// /*
// if existingResultSet := CurrentState(ctx).ResultSet; existingResultSet != nil {
// return existingResultSet.TableInfo, nil
// }
// */
tableInfo, err := c.tableService.Describe(ctx, c.tableName)
if err != nil {
return nil, errors.Wrapf(err, "cannot describe %v", c.tableName)
}
return tableInfo, nil
}
// tableInfo, err := c.tableService.Describe(ctx, c.tableName)
// if err != nil {
// return nil, errors.Wrapf(err, "cannot describe %v", c.tableName)
// }
// return tableInfo, nil
// }

View file

@ -4,7 +4,6 @@ import (
"context"
"github.com/lmika/awstools/internal/common/ui/uimodels"
"github.com/lmika/awstools/internal/dynamo-browse/models/modexpr"
"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
"github.com/pkg/errors"
)
@ -41,56 +40,59 @@ func (c *TableWriteController) ToggleReadWrite() uimodels.Operation {
}
func (c *TableWriteController) Duplicate() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
uiCtx := uimodels.Ctx(ctx)
state := CurrentState(ctx)
if state.SelectedItem == nil {
return errors.New("no selected item")
} else if !state.InReadWriteMode {
return errors.New("not in read/write mode")
}
uiCtx.Input("Dup: ", uimodels.OperationFn(func(ctx context.Context) error {
modExpr, err := modexpr.Parse(uimodels.PromptValue(ctx))
if err != nil {
return err
}
newItem, err := modExpr.Patch(state.SelectedItem)
if err != nil {
return err
}
// TODO: preview new item
return nil
/*
return uimodels.OperationFn(func(ctx context.Context) error {
uiCtx := uimodels.Ctx(ctx)
uiCtx.Input("Put item? ", uimodels.OperationFn(func(ctx context.Context) error {
if uimodels.PromptValue(ctx) != "y" {
return errors.New("operation aborted")
}
state := CurrentState(ctx)
tableInfo, err := c.tableReadControllers.tableInfo(ctx)
if state.SelectedItem == nil {
return errors.New("no selected item")
} else if !state.InReadWriteMode {
return errors.New("not in read/write mode")
}
uiCtx.Input("Dup: ", uimodels.OperationFn(func(ctx context.Context) error {
modExpr, err := modexpr.Parse(uimodels.PromptValue(ctx))
if err != nil {
return err
}
// Delete the item
if err := c.tableService.Put(ctx, tableInfo, newItem); err != nil {
newItem, err := modExpr.Patch(state.SelectedItem)
if err != nil {
return err
}
// Rescan to get updated items
if err := c.tableReadControllers.doScan(ctx, true); err != nil {
return err
}
// TODO: preview new item
uiCtx := uimodels.Ctx(ctx)
uiCtx.Input("Put item? ", uimodels.OperationFn(func(ctx context.Context) error {
if uimodels.PromptValue(ctx) != "y" {
return errors.New("operation aborted")
}
tableInfo, err := c.tableReadControllers.tableInfo(ctx)
if err != nil {
return err
}
// Delete the item
if err := c.tableService.Put(ctx, tableInfo, newItem); err != nil {
return err
}
// Rescan to get updated items
// if err := c.tableReadControllers.doScan(ctx, true); err != nil {
// return err
// }
return nil
}))
return nil
}))
return nil
}))
return nil
})
})
*/
}
func (c *TableWriteController) Delete() uimodels.Operation {
@ -111,20 +113,22 @@ func (c *TableWriteController) Delete() uimodels.Operation {
return errors.New("operation aborted")
}
tableInfo, err := c.tableReadControllers.tableInfo(ctx)
if err != nil {
return err
}
/*
tableInfo, err := c.tableReadControllers.tableInfo(ctx)
if err != nil {
return err
}
// Delete the item
if err := c.tableService.Delete(ctx, tableInfo, state.SelectedItem); err != nil {
return err
}
// Delete the item
if err := c.tableService.Delete(ctx, tableInfo, state.SelectedItem); err != nil {
return err
}
*/
// Rescan to get updated items
if err := c.tableReadControllers.doScan(ctx, true); err != nil {
return err
}
// if err := c.tableReadControllers.doScan(ctx, true); err != nil {
// return err
// }
uiCtx.Message("Item deleted")
return nil

View file

@ -4,8 +4,6 @@ import (
"context"
"testing"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/common/ui/uimodels"
"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"
@ -15,6 +13,8 @@ import (
)
func TestTableWriteController_ToggleReadWrite(t *testing.T) {
t.Skip("needs to be updated")
twc, _, closeFn := setupController(t)
t.Cleanup(closeFn)
@ -68,6 +68,8 @@ func TestTableWriteController_Delete(t *testing.T) {
err = op.Execute(ctx)
assert.NoError(t, err)
_ = uiCtx
/*
promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput)
assert.True(t, ok)
@ -81,6 +83,7 @@ func TestTableWriteController_Delete(t *testing.T) {
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) {
@ -106,7 +109,9 @@ func TestTableWriteController_Delete(t *testing.T) {
// Should prompt first
err = op.Execute(ctx)
assert.NoError(t, err)
_ = uiCtx
/*
promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput)
assert.True(t, ok)
@ -120,6 +125,7 @@ func TestTableWriteController_Delete(t *testing.T) {
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) {

View file

@ -14,6 +14,15 @@ type Provider struct {
client *dynamodb.Client
}
func (p *Provider) ListTables(ctx context.Context) ([]string, error) {
out, err := p.client.ListTables(ctx, &dynamodb.ListTablesInput{})
if err != nil {
return nil, errors.Wrapf(err, "cannot list tables")
}
return out.TableNames, nil
}
func (p *Provider) DescribeTable(ctx context.Context, tableName string) (*models.TableInfo, error) {
out, err := p.client.DescribeTable(ctx, &dynamodb.DescribeTableInput{
TableName: aws.String(tableName),

View file

@ -8,6 +8,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)
DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error

View file

@ -18,6 +18,10 @@ func NewService(provider TableProvider) *Service {
}
}
func (s *Service) ListTables(ctx context.Context) ([]string, error) {
return s.provider.ListTables(ctx)
}
func (s *Service) Describe(ctx context.Context, table string) (*models.TableInfo, error) {
return s.provider.DescribeTable(ctx, table)
}

View file

@ -1,7 +0,0 @@
package ui
import tea "github.com/charmbracelet/bubbletea"
type MessagePublisher interface {
Send(msg tea.Msg)
}

View file

@ -1,289 +1,50 @@
package ui
import (
"context"
"fmt"
"strings"
"text/tabwriter"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
table "github.com/calyptia/go-bubble-table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/common/ui/commandctrl"
"github.com/lmika/awstools/internal/common/ui/dispatcher"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/common/ui/uimodels"
"github.com/lmika/awstools/internal/dynamo-browse/controllers"
"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/tableselect"
)
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
type Model struct {
tableReadController *controllers.TableReadController
commandController *commandctrl.CommandController
inactiveHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1"))
)
type uiModel struct {
table table.Model
viewport viewport.Model
tableWidth, tableHeight int
ready bool
//resultSet *models.ResultSet
state controllers.State
message string
pendingInput *events.PromptForInput
textInput textinput.Model
dispatcher *dispatcher.Dispatcher
commandController *commandctrl.CommandController
tableReadController *controllers.TableReadController
tableWriteController *controllers.TableWriteController
root tea.Model
}
func NewModel(dispatcher *dispatcher.Dispatcher, commandController *commandctrl.CommandController, tableReadController *controllers.TableReadController, tableWriteController *controllers.TableWriteController) tea.Model {
tbl := table.New([]string{"pk", "sk"}, 100, 20)
rows := make([]table.Row, 0)
tbl.SetRows(rows)
func NewModel(rc *controllers.TableReadController, cc *commandctrl.CommandController) Model {
dtv := dynamotableview.New(rc, cc)
div := dynamoitemview.New()
textInput := textinput.New()
model := uiModel{
table: tbl,
message: "Press s to scan",
textInput: textInput,
dispatcher: dispatcher,
commandController: commandController,
tableReadController: tableReadController,
tableWriteController: tableWriteController,
}
return model
}
func (m uiModel) Init() tea.Cmd {
m.invokeOperation(context.Background(), m.tableReadController.Scan())
return nil
}
func (m *uiModel) updateTable() {
if !m.ready {
return
}
resultSet := m.state.ResultSet
newTbl := table.New(resultSet.Columns, m.tableWidth, m.tableHeight)
newRows := make([]table.Row, len(resultSet.Items))
for i, r := range resultSet.Items {
newRows[i] = itemTableRow{resultSet, r}
}
newTbl.SetRows(newRows)
m.table = newTbl
}
func (m *uiModel) selectedItem() (itemTableRow, bool) {
resultSet := m.state.ResultSet
if m.ready && resultSet != nil && len(resultSet.Items) > 0 {
selectedItem, ok := m.table.SelectedRow().(itemTableRow)
if ok {
return selectedItem, true
}
}
return itemTableRow{}, false
}
func (m *uiModel) updateViewportToSelectedMessage() {
selectedItem, ok := m.selectedItem()
if !ok {
m.viewport.SetContent("(no row selected)")
return
}
viewportContent := &strings.Builder{}
tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0)
for _, colName := range selectedItem.resultSet.Columns {
switch colVal := selectedItem.item[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)")
}
}
tabWriter.Flush()
m.viewport.SetContent(viewportContent.String())
}
func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var textInputCommands tea.Cmd
switch msg := msg.(type) {
// Local events
case controllers.NewResultSet:
m.state.ResultSet = msg.ResultSet
m.updateTable()
m.updateViewportToSelectedMessage()
case controllers.SetReadWrite:
m.state.InReadWriteMode = msg.NewValue
// Shared events
case events.Error:
m.message = "Error: " + msg.Error()
case events.Message:
m.message = string(msg)
case events.PromptForInput:
m.textInput.Prompt = msg.Prompt
m.textInput.Focus()
m.textInput.SetValue("")
m.pendingInput = &msg
// Tea events
case tea.WindowSizeMsg:
fixedViewsHeight := lipgloss.Height(m.headerView()) + lipgloss.Height(m.splitterView()) + lipgloss.Height(m.footerView())
viewportHeight := msg.Height / 2 // TODO: make this dynamic
if viewportHeight > 15 {
viewportHeight = 15
}
tableHeight := msg.Height - fixedViewsHeight - viewportHeight
if !m.ready {
m.viewport = viewport.New(msg.Width, viewportHeight)
m.viewport.SetContent("(no message selected)")
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - tableHeight - fixedViewsHeight
}
m.tableWidth, m.tableHeight = msg.Width, tableHeight
m.table.SetSize(m.tableWidth, m.tableHeight)
case tea.KeyMsg:
// If text input in focus, allow that to accept input messages
if m.pendingInput != nil {
switch msg.String() {
case "ctrl+c", "esc":
m.pendingInput = nil
case "enter":
m.invokeOperation(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone)
m.pendingInput = nil
default:
m.textInput, textInputCommands = m.textInput.Update(msg)
}
break
}
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "i":
m.table.GoUp()
m.updateViewportToSelectedMessage()
case "down", "k":
m.table.GoDown()
m.updateViewportToSelectedMessage()
// TODO: these should be moved somewhere else
case ":":
m.invokeOperation(context.Background(), m.commandController.Prompt())
case "s":
m.invokeOperation(context.Background(), m.tableReadController.Scan())
case "D":
m.invokeOperation(context.Background(), m.tableWriteController.Delete())
}
default:
m.textInput, textInputCommands = m.textInput.Update(msg)
}
updatedTable, tableMsgs := m.table.Update(msg)
updatedViewport, viewportMsgs := m.viewport.Update(msg)
m.table = updatedTable
m.viewport = updatedViewport
return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs)
}
func (m uiModel) invokeOperation(ctx context.Context, op uimodels.Operation) {
state := m.state
if selectedItem, ok := m.selectedItem(); ok {
state.SelectedItem = selectedItem.item
}
ctx = controllers.ContextWithState(ctx, state)
m.dispatcher.Start(ctx, op)
}
func (m uiModel) View() string {
if !m.ready {
return "Initializing"
}
if m.pendingInput != nil {
return lipgloss.JoinVertical(lipgloss.Top,
m.headerView(),
m.table.View(),
m.splitterView(),
m.viewport.View(),
m.textInput.View(),
)
}
return lipgloss.JoinVertical(lipgloss.Top,
m.headerView(),
m.table.View(),
m.splitterView(),
m.viewport.View(),
m.footerView(),
m := statusandprompt.New(
layout.NewVBox(layout.LastChildFixedAt(17), dtv, div),
"Hello world",
)
}
root := layout.FullScreen(tableselect.New(m))
func (m uiModel) headerView() string {
var titleText string
if m.state.ResultSet != nil {
titleText = "Table: " + m.state.ResultSet.TableInfo.Name
} else {
titleText = "No table"
return Model{
tableReadController: rc,
commandController: cc,
root: root,
}
title := activeHeaderStyle.Render(titleText)
line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
}
func (m uiModel) splitterView() string {
title := inactiveHeaderStyle.Render("Item")
line := inactiveHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
func (m Model) Init() tea.Cmd {
return m.tableReadController.Init()
}
func (m uiModel) footerView() string {
title := m.message
line := strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.root, cmd = m.root.Update(msg)
return m, cmd
}
func max(a, b int) int {
if a > b {
return a
}
return b
func (m Model) View() string {
return m.root.View()
}

View file

@ -0,0 +1,8 @@
package dynamoitemview
import "github.com/lmika/awstools/internal/dynamo-browse/models"
type NewItemSelected struct {
ResultSet *models.ResultSet
Item models.Item
}

View file

@ -0,0 +1,95 @@
package dynamoitemview
import (
"fmt"
"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"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
)
type Model struct {
ready bool
frameTitle frame.FrameTitle
viewport viewport.Model
w, h int
// model state
currentResultSet *models.ResultSet
selectedItem models.Item
}
func New() Model {
return Model{
frameTitle: frame.NewFrameTitle("Item", false),
viewport: viewport.New(100, 100),
}
}
func (Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case NewItemSelected:
m.currentResultSet = msg.ResultSet
m.selectedItem = msg.Item
m.updateViewportToSelectedMessage()
return m, nil
}
return m, nil
}
func (m Model) View() string {
if !m.ready {
return ""
}
return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.viewport.View())
}
func (m Model) Resize(w, h int) layout.ResizingModel {
m.w, m.h = w, h
if !m.ready {
m.viewport = viewport.New(w, h-m.frameTitle.HeaderHeight())
m.viewport.SetContent("")
m.ready = true
} else {
m.viewport.Width = w
m.viewport.Height = h - m.frameTitle.HeaderHeight()
}
m.frameTitle.Resize(w, h)
return m
}
func (m *Model) updateViewportToSelectedMessage() {
if m.selectedItem == nil {
m.viewport.SetContent("")
}
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)")
}
}
tabWriter.Flush()
m.viewport.Width = m.w
m.viewport.Height = m.h - m.frameTitle.HeaderHeight()
m.viewport.SetContent(viewportContent.String())
}

View file

@ -0,0 +1,150 @@
package dynamotableview
import (
table "github.com/calyptia/go-bubble-table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/common/ui/commandctrl"
"github.com/lmika/awstools/internal/dynamo-browse/controllers"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"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"
)
type Model struct {
tableReadControllers *controllers.TableReadController
commandCtrl *commandctrl.CommandController
frameTitle frame.FrameTitle
table table.Model
w, h int
// model state
resultSet *models.ResultSet
}
func New(tableReadControllers *controllers.TableReadController, commandCtrl *commandctrl.CommandController) Model {
tbl := table.New([]string{"pk", "sk"}, 100, 100)
rows := make([]table.Row, 0)
tbl.SetRows(rows)
frameTitle := frame.NewFrameTitle("No table", true)
return Model{
tableReadControllers: tableReadControllers,
commandCtrl: commandCtrl,
frameTitle: frameTitle,
table: tbl,
}
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case controllers.NewResultSet:
m.resultSet = msg.ResultSet
m.updateTable()
return m, m.postSelectedItemChanged
case tea.KeyMsg:
switch msg.String() {
// Table nav
case "i", "up":
m.table.GoUp()
return m, m.postSelectedItemChanged
case "k", "down":
m.table.GoDown()
return m, m.postSelectedItemChanged
// TEMP
case "s":
return m, m.tableReadControllers.Rescan(m.resultSet)
case ":":
return m, m.commandCtrl.Prompt()
// END TEMP
case "ctrl+c", "esc":
return m, tea.Quit
}
}
return m, nil
}
func (m Model) View() string {
return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View())
}
func (m Model) Resize(w, h int) layout.ResizingModel {
m.w, m.h = w, h
tblHeight := h - m.frameTitle.HeaderHeight()
m.table.SetSize(w, tblHeight)
m.frameTitle.Resize(w, h)
return m
}
func (m *Model) updateTable() {
resultSet := m.resultSet
m.frameTitle.SetTitle("Table: " + resultSet.TableInfo.Name)
newTbl := table.New(resultSet.Columns, m.w, m.h-m.frameTitle.HeaderHeight())
newRows := make([]table.Row, len(resultSet.Items))
for i, r := range resultSet.Items {
newRows[i] = itemTableRow{resultSet, r}
}
newTbl.SetRows(newRows)
m.table = newTbl
}
func (m *Model) selectedItem() (itemTableRow, bool) {
resultSet := m.resultSet
if resultSet != nil && len(resultSet.Items) > 0 {
selectedItem, ok := m.table.SelectedRow().(itemTableRow)
if ok {
return selectedItem, true
}
}
return itemTableRow{}, false
}
func (m *Model) postSelectedItemChanged() tea.Msg {
item, ok := m.selectedItem()
if !ok {
return nil
}
return dynamoitemview.NewItemSelected{ResultSet: item.resultSet, Item: item.item}
}
/*
func (m *Model) updateViewportToSelectedMessage() {
selectedItem, ok := m.selectedItem()
if !ok {
m.viewport.SetContent("(no row selected)")
return
}
viewportContent := &strings.Builder{}
tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0)
for _, colName := range selectedItem.resultSet.Columns {
switch colVal := selectedItem.item[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)")
}
}
tabWriter.Flush()
m.viewport.SetContent(viewportContent.String())
}
*/

View file

@ -1,4 +1,4 @@
package ui
package dynamotableview
import (
"fmt"

View file

@ -0,0 +1,58 @@
package frame
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils"
)
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
inactiveHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1"))
)
// Frame is a frame that appears in the
type FrameTitle struct {
header string
active bool
width int
}
func NewFrameTitle(header string, active bool) FrameTitle {
return FrameTitle{header, active, 0}
}
func (f *FrameTitle) SetTitle(title string) {
f.header = title
}
func (f FrameTitle) View() string {
return f.headerView()
}
func (f *FrameTitle) Resize(w, h int) {
f.width = w
}
func (f FrameTitle) HeaderHeight() int {
return lipgloss.Height(f.headerView())
}
func (f FrameTitle) headerView() string {
style := inactiveHeaderStyle
if f.active {
style = activeHeaderStyle
}
titleText := f.header
title := style.Render(titleText)
line := style.Render(strings.Repeat(" ", utils.Max(0, f.width-lipgloss.Width(title))))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
}

View file

@ -0,0 +1,40 @@
package layout
type BoxSize interface {
childSize(idx, cnt, available int) int
}
func EqualSize() BoxSize {
return equalSize{}
}
type equalSize struct {
}
func (l equalSize) childSize(idx, cnt, available int) int {
if cnt == 1 {
return available
}
childrenHeight := available / cnt
lastChildRem := available % cnt
if idx == cnt-1 {
return childrenHeight + lastChildRem
}
return childrenHeight
}
func LastChildFixedAt(size int) BoxSize {
return lastChildFixedAt{size}
}
type lastChildFixedAt struct {
lastChildSize int
}
func (l lastChildFixedAt) childSize(idx, cnt, available int) int {
if idx == cnt-1 {
return l.lastChildSize
}
return (equalSize{}).childSize(idx, cnt-1, available-l.lastChildSize)
}

View file

@ -0,0 +1,38 @@
package layout
import tea "github.com/charmbracelet/bubbletea"
// FullScreen returns a model which will allocate the resizing model the entire height and width of the screen.
func FullScreen(rm ResizingModel) tea.Model {
return fullScreenModel{submodel: rm}
}
type fullScreenModel struct {
w, h int
submodel ResizingModel
ready bool
}
func (f fullScreenModel) Init() tea.Cmd {
return f.submodel.Init()
}
func (f fullScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
f.ready = true
f.submodel = f.submodel.Resize(msg.Width, msg.Height)
return f, nil
}
newSubModel, cmd := f.submodel.Update(msg)
f.submodel = newSubModel.(ResizingModel)
return f, cmd
}
func (f fullScreenModel) View() string {
if !f.ready {
return ""
}
return f.submodel.View()
}

View file

@ -0,0 +1,108 @@
package layout
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils"
"strconv"
"strings"
)
// ResizingModel is a model that handles resizing events. The submodel will not get WindowSizeMessages but will
// guarantee to receive at least one resize event before the initial view.
type ResizingModel interface {
tea.Model
Resize(w, h int) ResizingModel
}
// Resize sends a resize message to the passed in model. If m implements ResizingModel, then Resize is called;
// otherwise, m is returned without any messages.
func Resize(m tea.Model, w, h int) tea.Model {
if rm, isRm := m.(ResizingModel); isRm {
return rm.Resize(w, h)
}
return m
}
// Model takes a tea-model and displays it as a resizing model. The model will be
// displayed with all the available space provided
func Model(m tea.Model) ResizingModel {
return &teaModel{submodel: m}
}
type teaModel struct {
submodel tea.Model
w, h int
}
func (t teaModel) Init() tea.Cmd {
return t.submodel.Init()
}
func (t teaModel) Update(msg tea.Msg) (m tea.Model, cmd tea.Cmd) {
t.submodel, cmd = t.submodel.Update(msg)
return t, cmd
}
func (t teaModel) View() string {
subview := t.submodel.View() + " (h: " + strconv.Itoa(t.h) + "\n"
subviewHeight := lipgloss.Height(subview)
subviewVPad := strings.Repeat("\n", utils.Max(t.h-subviewHeight-1, 0))
return lipgloss.JoinVertical(lipgloss.Top, subview, subviewVPad)
}
func (t teaModel) Resize(w, h int) ResizingModel {
t.w, t.h = w, h
return t
}
type ResizableModelHandler struct {
new func(w, h int) tea.Model
resize func(m tea.Model, w, h int) tea.Model
model tea.Model
}
// NewResizableModelHandler takes a tea model that requires a with and height during construction
// and has a resize method, and wraps it as a resizing model.
func NewResizableModelHandler(newModel func(w, h int) tea.Model) ResizableModelHandler {
return ResizableModelHandler{
new: newModel,
}
}
func (rmh ResizableModelHandler) WithResize(resizeFn func(m tea.Model, w, h int) tea.Model) ResizableModelHandler {
rmh.resize = resizeFn
return rmh
}
func (rmh ResizableModelHandler) Resize(w, h int) ResizingModel {
if rmh.model == nil {
rmh.model = rmh.new(w, h)
// TODO: handle init
} else if rmh.resize != nil {
rmh.model = rmh.resize(rmh.model, w, h)
}
return rmh
}
func (rmh ResizableModelHandler) Init() tea.Cmd {
return nil
}
func (rmh ResizableModelHandler) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if rmh.model == nil {
return rmh, nil
}
newModel, cmd := rmh.model.Update(msg)
rmh.model = newModel
return rmh, cmd
}
func (rmh ResizableModelHandler) View() string {
if rmh.model == nil {
return ""
}
return rmh.model.View()
}

View file

@ -0,0 +1,53 @@
package layout
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils"
)
// VBox is a model which will display its children vertically.
type VBox struct {
boxSize BoxSize
children []ResizingModel
}
func NewVBox(boxSize BoxSize, children ...ResizingModel) VBox {
return VBox{boxSize: boxSize, children: children}
}
func (vb VBox) Init() tea.Cmd {
var cc utils.CmdCollector
for _, c := range vb.children {
cc.Collect(c, c.Init())
}
return cc.Cmd()
}
func (vb VBox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cc utils.CmdCollector
for i, c := range vb.children {
vb.children[i] = cc.Collect(c.Update(msg)).(ResizingModel)
}
return vb, cc.Cmd()
}
func (vb VBox) View() string {
sb := new(strings.Builder)
for i, c := range vb.children {
if i > 0 {
sb.WriteRune('\n')
}
sb.WriteString(c.View())
}
return sb.String()
}
func (vb VBox) Resize(w, h int) ResizingModel {
for i, c := range vb.children {
childHeight := vb.boxSize.childSize(i, len(vb.children), h)
vb.children[i] = c.Resize(w, childHeight)
}
return vb
}

View file

@ -0,0 +1,19 @@
package modal
import tea "github.com/charmbracelet/bubbletea"
type newModePushed tea.Model
type modePopped struct{}
// PushMode pushes a new mode on the modal stack. The new mode will receive keyboard events.
func PushMode(newMode tea.Model) tea.Cmd {
return func() tea.Msg {
return newModePushed(newMode)
}
}
// PopMode pops the top-level mode from the modal stack. If there's no modes on the stack, this method does nothing.
func PopMode() tea.Msg {
return modePopped{}
}

View file

@ -0,0 +1,84 @@
package modal
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"
"log"
)
// Modal is a tea model which displays modes on a stack. Only the top-level model is display and will receive
// keyboard and mouse events.
type Modal struct {
baseMode tea.Model
modeStack []tea.Model
}
func New(baseMode tea.Model) Modal {
return Modal{baseMode: baseMode}
}
func (m Modal) Init() tea.Cmd {
return m.baseMode.Init()
}
// Push pushes a new model onto the modal stack
func (m *Modal) Push(model tea.Model) {
m.modeStack = append(m.modeStack, model)
log.Printf("pusing new mode: len = %v", len(m.modeStack))
}
// Pop pops a model from the stack
func (m *Modal) Pop() (p tea.Model) {
if len(m.modeStack) > 0 {
p = m.modeStack[len(m.modeStack)-1]
m.modeStack = m.modeStack[:len(m.modeStack)-1]
return p
}
return nil
}
// Len returns the number of models on the mode stack
func (m Modal) Len() int {
return len(m.modeStack)
}
func (m Modal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cc utils.CmdCollector
switch msg := msg.(type) {
case tea.KeyMsg, tea.MouseMsg:
// only notify top level stack
if len(m.modeStack) > 0 {
m.modeStack[len(m.modeStack)-1] = cc.Collect(m.modeStack[len(m.modeStack)-1].Update(msg))
} else {
m.baseMode = cc.Collect(m.baseMode.Update(msg))
}
default:
// notify all modes of other events
// TODO: is this right?
m.baseMode = cc.Collect(m.baseMode.Update(msg))
for i, s := range m.modeStack {
m.modeStack[i] = cc.Collect(s.Update(msg))
}
}
return m, cc.Cmd()
}
func (m Modal) View() string {
// only show top level mode
if len(m.modeStack) > 0 {
log.Printf("viewing mode stack: len = %v", len(m.modeStack))
return m.modeStack[len(m.modeStack)-1].View()
}
return m.baseMode.View()
}
func (m Modal) Resize(w, h int) layout.ResizingModel {
m.baseMode = layout.Resize(m.baseMode, w, h)
for i := range m.modeStack {
m.modeStack[i] = layout.Resize(m.modeStack[i], w, h)
}
return m
}

View file

@ -0,0 +1,99 @@
package statusandprompt
import (
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"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/utils"
)
// StatusAndPrompt is a resizing model which displays a submodel and a status bar. When the start prompt
// 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
statusMessage string
pendingInput *events.PromptForInputMsg
textInput textinput.Model
width int
}
func New(model layout.ResizingModel, initialMsg string) StatusAndPrompt {
textInput := textinput.New()
return StatusAndPrompt{model: model, statusMessage: initialMsg, textInput: textInput}
}
func (s StatusAndPrompt) Init() tea.Cmd {
return s.model.Init()
}
func (s StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case events.ErrorMsg:
s.statusMessage = "Error: " + msg.Error()
case events.StatusMsg:
s.statusMessage = string(s.statusMessage)
case events.MessageWithStatus:
s.statusMessage = msg.StatusMessage()
case events.PromptForInputMsg:
if s.pendingInput != nil {
// ignore, already in an input
return s, nil
}
s.textInput.Prompt = msg.Prompt
s.textInput.Focus()
s.textInput.SetValue("")
s.pendingInput = &msg
return s, nil
case tea.KeyMsg:
if s.pendingInput != nil {
switch msg.String() {
case "ctrl+c", "esc":
s.pendingInput = nil
case "enter":
pendingInput := s.pendingInput
s.pendingInput = nil
return s, pendingInput.OnDone(s.textInput.Value())
}
}
}
if s.pendingInput != nil {
var cc utils.CmdCollector
newTextInput, cmd := s.textInput.Update(msg)
cc.Add(cmd)
s.textInput = newTextInput
if _, isKey := msg.(tea.Key); !isKey {
s.model = cc.Collect(s.model.Update(msg)).(layout.ResizingModel)
}
return s, cc.Cmd()
}
newModel, cmd := s.model.Update(msg)
s.model = newModel.(layout.ResizingModel)
return s, cmd
}
func (s StatusAndPrompt) View() string {
return lipgloss.JoinVertical(lipgloss.Top, s.model.View(), s.viewStatus())
}
func (s StatusAndPrompt) Resize(w, h int) layout.ResizingModel {
s.width = w
submodelHeight := h - lipgloss.Height(s.viewStatus())
s.model = s.model.Resize(w, submodelHeight)
return s
}
func (s StatusAndPrompt) viewStatus() string {
if s.pendingInput != nil {
return s.textInput.View()
}
return s.statusMessage
}

View file

@ -0,0 +1,22 @@
package tableselect
import tea "github.com/charmbracelet/bubbletea"
func IndicateLoadingTables() tea.Cmd {
return func() tea.Msg {
return indicateLoadingTablesMsg{}
}
}
func ShowTableSelect(onSelected func(n string) tea.Cmd) tea.Cmd {
return func() tea.Msg {
return showTableSelectMsg{
onSelected: onSelected,
}
}
}
type indicateLoadingTablesMsg struct{}
type showTableSelectMsg struct {
onSelected func(n string) tea.Cmd
}

View file

@ -0,0 +1,27 @@
package tableselect
import "github.com/charmbracelet/bubbles/list"
type tableItem struct {
name string
}
func (ti tableItem) FilterValue() string {
return ""
}
func (ti tableItem) Title() string {
return ti.name
}
func (ti tableItem) Description() string {
return ""
}
func toListItems(xs []string) []list.Item {
ls := make([]list.Item, len(xs))
for i, x := range xs {
ls[i] = tableItem{name: x}
}
return ls
}

View file

@ -0,0 +1,52 @@
package tableselect
import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
)
var (
titleStyle = lipgloss.NewStyle().MarginLeft(2)
itemStyle = lipgloss.NewStyle().PaddingLeft(4)
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4)
)
type listController struct {
list list.Model
}
func newListController(tableNames []string, w, h int) listController {
items := toListItems(tableNames)
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = false
list := list.New(items, delegate, w, h)
list.SetShowTitle(false)
return listController{list: list}
}
func (l listController) Init() tea.Cmd {
return nil
}
func (l listController) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
newList, cmd := l.list.Update(msg)
l.list = newList
return l, cmd
}
func (l listController) View() string {
return l.list.View()
}
func (l listController) Resize(w, h int) layout.ResizingModel {
l.list.SetSize(w, h)
return l
}

View file

@ -0,0 +1,86 @@
package tableselect
import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"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/utils"
)
type Model struct {
frameTitle frame.FrameTitle
listController listController
submodel tea.Model
pendingSelection *controllers.PromptForTableMsg
isLoading bool
w, h int
}
func New(submodel tea.Model) Model {
frameTitle := frame.NewFrameTitle("Select table", false)
return Model{frameTitle: frameTitle, submodel: submodel}
}
func (m Model) Init() tea.Cmd {
return m.submodel.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cc utils.CmdCollector
switch msg := msg.(type) {
case controllers.PromptForTableMsg:
m.isLoading = false
m.pendingSelection = &msg
m.listController = newListController(msg.Tables, m.w, m.h-m.frameTitle.HeaderHeight())
return m, nil
case indicateLoadingTablesMsg:
m.isLoading = true
return m, nil
case tea.KeyMsg:
if m.pendingSelection != nil {
switch msg.String() {
case "enter":
if m.listController.list.FilterState() != list.Filtering {
var sel controllers.PromptForTableMsg
sel, m.pendingSelection = *m.pendingSelection, nil
return m, sel.OnSelected(m.listController.list.SelectedItem().(tableItem).name)
}
}
m.listController = cc.Collect(m.listController.Update(msg)).(listController)
return m, cc.Cmd()
}
}
m.submodel = cc.Collect(m.submodel.Update(msg))
return m, cc.Cmd()
}
func (m Model) View() string {
if m.pendingSelection != nil {
return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.listController.View())
} else if m.isLoading {
return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), "Loading tables")
}
return m.submodel.View()
}
func (m Model) shouldShow() bool {
return m.pendingSelection != nil || m.isLoading
}
func (m Model) Resize(w, h int) layout.ResizingModel {
m.w, m.h = w, h
m.submodel = layout.Resize(m.submodel, w, h)
m.frameTitle.Resize(w, h)
if m.pendingSelection != nil {
m.listController = m.listController.Resize(w, h-m.frameTitle.HeaderHeight()).(listController)
}
return m
}

View file

@ -0,0 +1,8 @@
package utils
func Max(x, y int) int {
if x > y {
return x
}
return y
}

View file

@ -0,0 +1,31 @@
package utils
import tea "github.com/charmbracelet/bubbletea"
type CmdCollector struct {
cmds []tea.Cmd
}
func (c *CmdCollector) Add(cmd tea.Cmd) {
if cmd != nil {
c.cmds = append(c.cmds, cmd)
}
}
func (c *CmdCollector) Collect(m tea.Model, cmd tea.Cmd) tea.Model {
if cmd != nil {
c.cmds = append(c.cmds, cmd)
}
return m
}
func (c CmdCollector) Cmd() tea.Cmd {
switch len(c.cmds) {
case 0:
return nil
case 1:
return c.cmds[0]
default:
return tea.Batch(c.cmds...)
}
}

View file

@ -14,7 +14,6 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/common/ui/dispatcher"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/common/ui/uimodels"
"github.com/lmika/awstools/internal/sqs-browse/controllers"
"github.com/lmika/awstools/internal/sqs-browse/models"
)
@ -38,7 +37,7 @@ type uiModel struct {
tableRows []table.Row
message string
pendingInput *events.PromptForInput
pendingInput *events.PromptForInputMsg
textInput textinput.Model
dispatcher *dispatcher.Dispatcher
@ -96,14 +95,15 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
// Shared messages
case events.Error:
case events.ErrorMsg:
m.message = "Error: " + msg.Error()
case events.Message:
case events.StatusMsg:
m.message = string(msg)
case events.PromptForInput:
m.textInput.Focus()
m.textInput.SetValue("")
m.pendingInput = &msg
case events.PromptForInputMsg:
// TODO
//m.textInput.Focus()
//m.textInput.SetValue("")
//m.pendingInput = &msg
// Local messages
case NewMessagesEvent:
@ -143,7 +143,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+c", "esc":
m.pendingInput = nil
case "enter":
m.dispatcher.Start(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone)
//m.dispatcher.Start(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone)
m.pendingInput = nil
default:
m.textInput, textInputCommands = m.textInput.Update(msg)

View file

@ -2,14 +2,14 @@ package main
import (
"context"
"github.com/brianvoe/gofakeit/v6"
"github.com/google/uuid"
"log"
"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"
@ -32,7 +32,7 @@ func main() {
if _, err = dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{
TableName: aws.String(tableName),
}); err != nil {
log.Printf("warn: cannot delete table: %v", tableName)
log.Printf("warn: cannot delete table: %v: %v", tableName, err)
}
if _, err = dynamoClient.CreateTable(ctx, &dynamodb.CreateTableInput{
@ -61,6 +61,8 @@ func main() {
dynamoProvider := dynamo.NewProvider(dynamoClient)
tableService := tables.NewService(dynamoProvider)
_, _ = tableService, tableInfo
for i := 0; i < totalItems; i++ {
key := uuid.New().String()
if err := tableService.Put(ctx, tableInfo, models.Item{