Merge remote-tracking branch 'origin/feature/dynamo-query'

# Conflicts:
#	cmd/dynamo-browse/main.go
#	cmd/ssm-browse/main.go
#	docker-compose.yml
#	internal/dynamo-browse/ui/model.go
#	test/cmd/load-test-table/main.go
This commit is contained in:
Leon Mika 2022-07-14 21:23:31 +10:00
commit ffca588a2c
77 changed files with 2986 additions and 572 deletions

View file

@ -2,6 +2,8 @@ package commandctrl
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/pkg/errors"
"log"
"strings"
"github.com/lmika/awstools/internal/common/ui/events"
@ -35,15 +37,18 @@ func (c *CommandController) Prompt() tea.Cmd {
}
func (c *CommandController) Execute(commandInput string) tea.Cmd {
log.Println("Received input: ", commandInput)
input := strings.TrimSpace(commandInput)
if input == "" {
return nil
}
tokens := shellwords.Split(input)
log.Println("Tokens: ", tokens)
command := c.lookupCommand(tokens[0])
if command == nil {
return events.SetStatus("no such command: " + tokens[0])
log.Println("No such command: ", tokens)
return events.SetError(errors.New("no such command: " + tokens[0]))
}
return command(tokens[1:])
@ -51,6 +56,7 @@ func (c *CommandController) Execute(commandInput string) tea.Cmd {
func (c *CommandController) lookupCommand(name string) Command {
for ctx := c.commandList; ctx != nil; ctx = ctx.parent {
log.Printf("Looking in command list: %v", c.commandList)
if cmd, ok := ctx.Commands[name]; ok {
return cmd
}

View file

@ -10,6 +10,12 @@ func Error(err error) tea.Msg {
return ErrorMsg(err)
}
func SetError(err error) tea.Cmd {
return func() tea.Msg {
return Error(err)
}
}
func SetStatus(msg string) tea.Cmd {
return func() tea.Msg {
return StatusMsg(msg)
@ -25,6 +31,20 @@ func PromptForInput(prompt string, onDone func(value string) tea.Cmd) tea.Cmd {
}
}
func Confirm(prompt string, onYes func() tea.Cmd) tea.Cmd {
return PromptForInput(prompt, func(value string) tea.Cmd {
if value == "y" {
return onYes()
}
return nil
})
}
type MessageWithStatus interface {
StatusMessage() string
}
type MessageWithMode interface {
MessageWithStatus
ModeMessage() string
}

View file

@ -10,6 +10,9 @@ type ErrorMsg error
// Message indicates that a message should be shown to the user
type StatusMsg string
// ModeMessage indicates that the mode should be changed to the following
type ModeMessage string
// PromptForInput indicates that the context is requesting a line of input
type PromptForInputMsg struct {
Prompt string

View file

@ -6,8 +6,18 @@ import (
"os"
)
func EnableLogging() (closeFn func()) {
f, err := tea.LogToFile("debug.log", "debug")
func EnableLogging(logFile string) (closeFn func()) {
if logFile == "" {
tempFile, err := os.CreateTemp("", "debug.log")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)
}
tempFile.Close()
logFile = tempFile.Name()
}
f, err := tea.LogToFile(logFile, "debug")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)

View file

@ -0,0 +1,18 @@
package osstyle
type ColorScheme int
const (
ColorSchemeUnknown ColorScheme = iota
ColorSchemeLightMode
ColorSchemeDarkMode
)
var getOSColorScheme func() ColorScheme = nil
func CurrentColorScheme() ColorScheme {
if getOSColorScheme == nil {
return ColorSchemeUnknown
}
return getOSColorScheme()
}

View file

@ -0,0 +1,27 @@
package osstyle
import (
"log"
"os/exec"
)
// Usage: https://stefan.sofa-rockers.org/2018/10/23/macos-dark-mode-terminal-vim/
func darwinGetOSColorScheme() ColorScheme {
d, err := exec.Command("defaults", "read", "-g", "AppleInterfaceStyle").Output()
if err != nil {
log.Printf("cannot get current OS color scheme: %v", err)
return ColorSchemeUnknown
}
switch string(d) {
case "Dark\n":
return ColorSchemeDarkMode
case "Light\n":
return ColorSchemeLightMode
}
return ColorSchemeUnknown
}
func init() {
getOSColorScheme = darwinGetOSColorScheme
}

View file

@ -0,0 +1,96 @@
package controllers
import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"github.com/pkg/errors"
"strings"
)
type attrPath []string
func newAttrPath(expr string) attrPath {
return strings.Split(expr, ".")
}
func (ap attrPath) follow(item models.Item) (types.AttributeValue, error) {
var step types.AttributeValue
for i, seg := range ap {
if i == 0 {
step = item[seg]
continue
}
switch s := step.(type) {
case *types.AttributeValueMemberM:
step = s.Value[seg]
default:
return nil, errors.Errorf("seg %v expected to be a map", i)
}
}
return step, nil
}
func (ap attrPath) deleteAt(item models.Item) error {
if len(ap) == 1 {
delete(item, ap[0])
return nil
}
var step types.AttributeValue
for i, seg := range ap[:len(ap)-1] {
if i == 0 {
step = item[seg]
continue
}
switch s := step.(type) {
case *types.AttributeValueMemberM:
step = s.Value[seg]
default:
return errors.Errorf("seg %v expected to be a map", i)
}
}
lastSeg := ap[len(ap)-1]
switch s := step.(type) {
case *types.AttributeValueMemberM:
delete(s.Value, lastSeg)
default:
return errors.Errorf("last seg expected to be a map, but was %T", lastSeg)
}
return nil
}
func (ap attrPath) setAt(item models.Item, newValue types.AttributeValue) error {
if len(ap) == 1 {
item[ap[0]] = newValue
return nil
}
var step types.AttributeValue
for i, seg := range ap[:len(ap)-1] {
if i == 0 {
step = item[seg]
continue
}
switch s := step.(type) {
case *types.AttributeValueMemberM:
step = s.Value[seg]
default:
return errors.Errorf("seg %v expected to be a map", i)
}
}
lastSeg := ap[len(ap)-1]
switch s := step.(type) {
case *types.AttributeValueMemberM:
s.Value[lastSeg] = newValue
default:
return errors.Errorf("last seg expected to be a map, but was %T", lastSeg)
}
return nil
}

View file

@ -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)
}

View file

@ -2,17 +2,42 @@ package controllers
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/dynamo-browse/models"
)
type NewResultSet struct {
ResultSet *models.ResultSet
ResultSet *models.ResultSet
currentFilter string
filteredCount int
statusMessage string
}
func (rs NewResultSet) ModeMessage() string {
var modeLine string
if rs.ResultSet.Query != nil {
modeLine = rs.ResultSet.Query.String()
} else {
modeLine = "All results"
}
if rs.currentFilter != "" {
modeLine = fmt.Sprintf("%v - Filter: '%v'", modeLine, rs.currentFilter)
}
return modeLine
}
func (rs NewResultSet) StatusMessage() string {
return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items()))
if rs.statusMessage != "" {
return rs.statusMessage
}
if rs.currentFilter != "" {
return fmt.Sprintf("%d of %d items returned", rs.filteredCount, len(rs.ResultSet.Items()))
} else {
return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items()))
}
}
type SetReadWrite struct {

View file

@ -0,0 +1,14 @@
package controllers
import (
"context"
"github.com/lmika/awstools/internal/dynamo-browse/models"
)
type TableReadService interface {
ListTables(background context.Context) ([]string, error)
Describe(ctx context.Context, table string) (*models.TableInfo, error)
Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error)
Filter(resultSet *models.ResultSet, filter string) *models.ResultSet
ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, query models.Queryable) (*models.ResultSet, error)
}

View file

@ -1,28 +1,69 @@
package controllers
import (
"context"
"sync"
"github.com/lmika/awstools/internal/dynamo-browse/models"
)
type State struct {
ResultSet *models.ResultSet
SelectedItem models.Item
// InReadWriteMode indicates whether modifications can be made to the table
InReadWriteMode bool
mutex *sync.Mutex
resultSet *models.ResultSet
filter string
}
type stateContextKeyType struct{}
var stateContextKey = stateContextKeyType{}
func CurrentState(ctx context.Context) State {
state, _ := ctx.Value(stateContextKey).(State)
return state
func NewState() *State {
return &State{
mutex: new(sync.Mutex),
}
}
func ContextWithState(ctx context.Context, state State) context.Context {
return context.WithValue(ctx, stateContextKey, state)
func (s *State) ResultSet() *models.ResultSet {
s.mutex.Lock()
defer s.mutex.Unlock()
return s.resultSet
}
func (s *State) Filter() string {
s.mutex.Lock()
defer s.mutex.Unlock()
return s.filter
}
func (s *State) withResultSet(rs func(*models.ResultSet)) {
s.mutex.Lock()
defer s.mutex.Unlock()
rs(s.resultSet)
}
func (s *State) withResultSetReturningError(rs func(*models.ResultSet) error) (err error) {
s.withResultSet(func(set *models.ResultSet) {
err = rs(set)
})
return err
}
func (s *State) setResultSetAndFilter(resultSet *models.ResultSet, filter string) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.resultSet = resultSet
s.filter = filter
}
func (s *State) buildNewResultSetMessage(statusMessage string) NewResultSet {
s.mutex.Lock()
defer s.mutex.Unlock()
var filteredCount int = 0
if s.filter != "" {
for i := range s.resultSet.Items() {
if !s.resultSet.Hidden(i) {
filteredCount += 1
}
}
}
return NewResultSet{s.resultSet, s.filter, filteredCount, statusMessage}
}

View file

@ -2,26 +2,30 @@ package controllers
import (
"context"
"encoding/csv"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
"github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr"
"github.com/pkg/errors"
"os"
"sync"
)
type TableReadController struct {
tableService *tables.Service
tableService TableReadService
tableName string
// state
mutex *sync.Mutex
resultSet *models.ResultSet
filter string
mutex *sync.Mutex
state *State
//resultSet *models.ResultSet
//filter string
}
func NewTableReadController(tableService *tables.Service, tableName string) *TableReadController {
func NewTableReadController(state *State, tableService TableReadService, tableName string) *TableReadController {
return &TableReadController{
state: state,
tableService: tableService,
tableName: tableName,
mutex: new(sync.Mutex),
@ -67,55 +71,106 @@ func (c *TableReadController) ScanTable(name string) tea.Cmd {
return events.Error(err)
}
return c.setResultSetAndFilter(resultSet, c.filter)
return c.setResultSetAndFilter(resultSet, c.state.Filter())
}
}
func (c *TableReadController) PromptForQuery() tea.Cmd {
return func() tea.Msg {
return events.PromptForInputMsg{
Prompt: "query: ",
OnDone: func(value string) tea.Cmd {
if value == "" {
return func() tea.Msg {
resultSet := c.state.ResultSet()
return c.doScan(context.Background(), resultSet, nil)
}
}
expr, err := queryexpr.Parse(value)
if err != nil {
return events.SetError(err)
}
return func() tea.Msg {
resultSet := c.state.ResultSet()
newResultSet, err := c.tableService.ScanOrQuery(context.Background(), resultSet.TableInfo, expr)
if err != nil {
return events.Error(err)
}
return c.setResultSetAndFilter(newResultSet, "")
}
},
}
}
}
func (c *TableReadController) Rescan() tea.Cmd {
return func() tea.Msg {
return c.doScan(context.Background(), c.resultSet)
resultSet := c.state.ResultSet()
return c.doScan(context.Background(), resultSet, resultSet.Query)
}
}
func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet) tea.Msg {
newResultSet, err := c.tableService.Scan(ctx, resultSet.TableInfo)
func (c *TableReadController) ExportCSV(filename string) tea.Cmd {
return func() tea.Msg {
resultSet := c.state.ResultSet()
if resultSet == nil {
return events.Error(errors.New("no result set"))
}
f, err := os.Create(filename)
if err != nil {
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
}
defer f.Close()
cw := csv.NewWriter(f)
defer cw.Flush()
columns := resultSet.Columns()
if err := cw.Write(columns); err != nil {
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
}
row := make([]string, len(columns))
for _, item := range resultSet.Items() {
for i, col := range columns {
row[i], _ = item.AttributeValueAsString(col)
}
if err := cw.Write(row); err != nil {
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
}
}
return nil
}
}
func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable) tea.Msg {
newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query)
if err != nil {
return events.Error(err)
}
newResultSet = c.tableService.Filter(newResultSet, c.filter)
newResultSet = c.tableService.Filter(newResultSet, c.state.Filter())
return c.setResultSetAndFilter(newResultSet, c.filter)
}
func (c *TableReadController) ResultSet() *models.ResultSet {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.resultSet
return c.setResultSetAndFilter(newResultSet, c.state.Filter())
}
func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg {
c.mutex.Lock()
defer c.mutex.Unlock()
c.resultSet = resultSet
c.filter = filter
return NewResultSet{resultSet}
c.state.setResultSetAndFilter(resultSet, filter)
return c.state.buildNewResultSetMessage("")
}
func (c *TableReadController) Unmark() tea.Cmd {
return func() tea.Msg {
resultSet := c.ResultSet()
for i := range resultSet.Items() {
resultSet.SetMark(i, false)
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.resultSet = resultSet
c.state.withResultSet(func(resultSet *models.ResultSet) {
for i := range resultSet.Items() {
resultSet.SetMark(i, false)
}
})
return ResultSetUpdated{}
}
}
@ -126,7 +181,7 @@ func (c *TableReadController) Filter() tea.Cmd {
Prompt: "filter: ",
OnDone: func(value string) tea.Cmd {
return func() tea.Msg {
resultSet := c.ResultSet()
resultSet := c.state.ResultSet()
newResultSet := c.tableService.Filter(resultSet, value)
return c.setResultSetAndFilter(newResultSet, value)

View file

@ -0,0 +1,257 @@
package controllers_test
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/dynamo-browse/controllers"
"github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo"
"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
"github.com/lmika/awstools/test/testdynamo"
"github.com/stretchr/testify/assert"
"os"
"strings"
"testing"
)
func TestTableReadController_InitTable(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
t.Run("should prompt for table if no table name provided", func(t *testing.T) {
readController := controllers.NewTableReadController(controllers.NewState(), service, "")
cmd := readController.Init()
event := cmd()
assert.IsType(t, controllers.PromptForTableMsg{}, event)
})
t.Run("should scan table if table name provided", func(t *testing.T) {
readController := controllers.NewTableReadController(controllers.NewState(), service, "")
cmd := readController.Init()
event := cmd()
assert.IsType(t, controllers.PromptForTableMsg{}, event)
})
}
func TestTableReadController_ListTables(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
readController := controllers.NewTableReadController(controllers.NewState(), service, "")
t.Run("returns a list of tables", func(t *testing.T) {
cmd := readController.ListTables()
event := cmd().(controllers.PromptForTableMsg)
assert.Equal(t, []string{"alpha-table", "bravo-table"}, event.Tables)
selectedCmd := event.OnSelected("alpha-table")
selectedEvent := selectedCmd()
resultSet := selectedEvent.(controllers.NewResultSet)
assert.Equal(t, "alpha-table", resultSet.ResultSet.TableInfo.Name)
assert.Equal(t, "pk", resultSet.ResultSet.TableInfo.Keys.PartitionKey)
assert.Equal(t, "sk", resultSet.ResultSet.TableInfo.Keys.SortKey)
})
}
func TestTableReadController_ExportCSV(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
readController := controllers.NewTableReadController(controllers.NewState(), service, "bravo-table")
t.Run("should export result set to CSV file", func(t *testing.T) {
tempFile := tempFile(t)
invokeCommand(t, readController.Init())
invokeCommand(t, readController.ExportCSV(tempFile))
bts, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, string(bts), strings.Join([]string{
"pk,sk,alpha,beta,gamma\n",
"abc,222,This is another some value,1231,\n",
"bbb,131,,2468,foobar\n",
"foo,bar,This is some value,,\n",
}, ""))
})
t.Run("should return error if result set is not set", func(t *testing.T) {
tempFile := tempFile(t)
readController := controllers.NewTableReadController(controllers.NewState(), service, "non-existant-table")
invokeCommandExpectingError(t, readController.Init())
invokeCommandExpectingError(t, readController.ExportCSV(tempFile))
})
// Hidden items?
}
func TestTableReadController_Query(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
readController := controllers.NewTableReadController(controllers.NewState(), service, "bravo-table")
t.Run("should run scan with filter based on user query", func(t *testing.T) {
tempFile := tempFile(t)
invokeCommand(t, readController.Init())
invokeCommandWithPrompts(t, readController.PromptForQuery(), `pk ^= "abc"`)
invokeCommand(t, readController.ExportCSV(tempFile))
bts, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, string(bts), strings.Join([]string{
"pk,sk,alpha,beta\n",
"abc,222,This is another some value,1231\n",
}, ""))
})
t.Run("should return error if result set is not set", func(t *testing.T) {
tempFile := tempFile(t)
readController := controllers.NewTableReadController(controllers.NewState(), service, "non-existant-table")
invokeCommandExpectingError(t, readController.Init())
invokeCommandExpectingError(t, readController.ExportCSV(tempFile))
})
}
func tempFile(t *testing.T) string {
t.Helper()
tempFile, err := os.CreateTemp("", "export.csv")
assert.NoError(t, err)
tempFile.Close()
t.Cleanup(func() {
os.Remove(tempFile.Name())
})
return tempFile.Name()
}
func invokeCommand(t *testing.T, cmd tea.Cmd) tea.Msg {
msg := cmd()
err, isErr := msg.(events.ErrorMsg)
if isErr {
assert.Fail(t, fmt.Sprintf("expected no error but got one: %v", err))
}
return msg
}
func invokeCommandWithPrompt(t *testing.T, cmd tea.Cmd, promptValue string) {
msg := cmd()
pi, isPi := msg.(events.PromptForInputMsg)
if !isPi {
assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one"))
}
invokeCommand(t, pi.OnDone(promptValue))
}
func invokeCommandWithPrompts(t *testing.T, cmd tea.Cmd, promptValues ...string) {
msg := cmd()
for _, promptValue := range promptValues {
pi, isPi := msg.(events.PromptForInputMsg)
if !isPi {
assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one"))
}
msg = invokeCommand(t, pi.OnDone(promptValue))
}
}
func invokeCommandWithPromptsExpectingError(t *testing.T, cmd tea.Cmd, promptValues ...string) {
msg := cmd()
for _, promptValue := range promptValues {
pi, isPi := msg.(events.PromptForInputMsg)
if !isPi {
assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one"))
}
msg = invokeCommand(t, pi.OnDone(promptValue))
}
_, isErr := msg.(events.ErrorMsg)
assert.True(t, isErr)
}
func invokeCommandExpectingError(t *testing.T, cmd tea.Cmd) {
msg := cmd()
_, isErr := msg.(events.ErrorMsg)
assert.True(t, isErr)
}
var testData = []testdynamo.TestData{
{
TableName: "alpha-table",
Data: []map[string]interface{}{
{
"pk": "abc",
"sk": "111",
"alpha": "This is some value",
"age": 23,
"address": map[string]any{
"no": 123,
"street": "Fake st.",
},
},
{
"pk": "abc",
"sk": "222",
"alpha": "This is another some value",
"beta": 1231,
},
{
"pk": "bbb",
"sk": "131",
"beta": 2468,
"gamma": "foobar",
},
},
},
{
TableName: "bravo-table",
Data: []map[string]interface{}{
{
"pk": "foo",
"sk": "bar",
"alpha": "This is some value",
},
{
"pk": "abc",
"sk": "222",
"alpha": "This is another some value",
"beta": 1231,
},
{
"pk": "bbb",
"sk": "131",
"beta": 2468,
"gamma": "foobar",
},
},
},
}

View file

@ -3,18 +3,23 @@ package controllers
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
"github.com/pkg/errors"
)
type TableWriteController struct {
state *State
tableService *tables.Service
tableReadControllers *TableReadController
}
func NewTableWriteController(tableService *tables.Service, tableReadControllers *TableReadController) *TableWriteController {
func NewTableWriteController(state *State, tableService *tables.Service, tableReadControllers *TableReadController) *TableWriteController {
return &TableWriteController{
state: state,
tableService: tableService,
tableReadControllers: tableReadControllers,
}
@ -22,16 +27,232 @@ func NewTableWriteController(tableService *tables.Service, tableReadControllers
func (twc *TableWriteController) ToggleMark(idx int) tea.Cmd {
return func() tea.Msg {
resultSet := twc.tableReadControllers.ResultSet()
resultSet.SetMark(idx, !resultSet.Marked(idx))
twc.state.withResultSet(func(resultSet *models.ResultSet) {
resultSet.SetMark(idx, !resultSet.Marked(idx))
})
return ResultSetUpdated{}
}
}
func (twc *TableWriteController) NewItem() tea.Cmd {
return func() tea.Msg {
// Work out which keys we need to prompt for
rs := twc.state.ResultSet()
keyPrompts := &promptSequence{
prompts: []string{rs.TableInfo.Keys.PartitionKey + ": "},
}
if rs.TableInfo.Keys.SortKey != "" {
keyPrompts.prompts = append(keyPrompts.prompts, rs.TableInfo.Keys.SortKey+": ")
}
keyPrompts.onAllDone = func(values []string) tea.Msg {
twc.state.withResultSet(func(set *models.ResultSet) {
newItem := models.Item{}
// TODO: deal with keys of different type
newItem[rs.TableInfo.Keys.PartitionKey] = &types.AttributeValueMemberS{Value: values[0]}
if len(values) == 2 {
newItem[rs.TableInfo.Keys.SortKey] = &types.AttributeValueMemberS{Value: values[1]}
}
set.AddNewItem(newItem, models.ItemAttribute{
New: true,
Dirty: true,
})
})
return twc.state.buildNewResultSetMessage("New item added")
}
return keyPrompts.next()
}
}
func (twc *TableWriteController) SetStringValue(idx int, key string) tea.Cmd {
return func() tea.Msg {
// Verify that the expression is valid
apPath := newAttrPath(key)
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
_, err := apPath.follow(set.Items()[idx])
return err
}); err != nil {
return events.Error(err)
}
return events.PromptForInputMsg{
Prompt: "string value: ",
OnDone: func(value string) tea.Cmd {
return func() tea.Msg {
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
err := apPath.setAt(set.Items()[idx], &types.AttributeValueMemberS{Value: value})
if err != nil {
return err
}
set.SetDirty(idx, true)
set.RefreshColumns()
return nil
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
}
},
}
}
}
func (twc *TableWriteController) SetNumberValue(idx int, key string) tea.Cmd {
return func() tea.Msg {
// Verify that the expression is valid
apPath := newAttrPath(key)
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
_, err := apPath.follow(set.Items()[idx])
return err
}); err != nil {
return events.Error(err)
}
return events.PromptForInputMsg{
Prompt: "number value: ",
OnDone: func(value string) tea.Cmd {
return func() tea.Msg {
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
err := apPath.setAt(set.Items()[idx], &types.AttributeValueMemberN{Value: value})
if err != nil {
return err
}
set.SetDirty(idx, true)
set.RefreshColumns()
return nil
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
}
},
}
}
}
func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Cmd {
return func() tea.Msg {
// Verify that the expression is valid
apPath := newAttrPath(key)
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
_, err := apPath.follow(set.Items()[idx])
return err
}); err != nil {
return events.Error(err)
}
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
err := apPath.deleteAt(set.Items()[idx])
if err != nil {
return err
}
set.SetDirty(idx, true)
set.RefreshColumns()
return nil
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
}
}
func (twc *TableWriteController) PutItem(idx int) tea.Cmd {
return func() tea.Msg {
resultSet := twc.state.ResultSet()
if !resultSet.IsDirty(idx) {
return events.Error(errors.New("item is not dirty"))
}
return events.PromptForInputMsg{
Prompt: "put item? ",
OnDone: func(value string) tea.Cmd {
return func() tea.Msg {
if value != "y" {
return nil
}
if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
}
},
}
}
}
func (twc *TableWriteController) TouchItem(idx int) tea.Cmd {
return func() tea.Msg {
resultSet := twc.state.ResultSet()
if resultSet.IsDirty(idx) {
return events.Error(errors.New("cannot touch dirty items"))
}
return events.PromptForInputMsg{
Prompt: "touch item? ",
OnDone: func(value string) tea.Cmd {
return func() tea.Msg {
if value != "y" {
return nil
}
if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
}
},
}
}
}
func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Cmd {
return func() tea.Msg {
resultSet := twc.state.ResultSet()
if resultSet.IsDirty(idx) {
return events.Error(errors.New("cannot noisy touch dirty items"))
}
return events.PromptForInputMsg{
Prompt: "noisy touch item? ",
OnDone: func(value string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if value != "y" {
return nil
}
item := resultSet.Items()[0]
if err := twc.tableService.Delete(ctx, resultSet.TableInfo, []models.Item{item}); err != nil {
return events.Error(err)
}
if err := twc.tableService.Put(ctx, resultSet.TableInfo, item); err != nil {
return events.Error(err)
}
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query)
}
},
}
}
}
func (twc *TableWriteController) DeleteMarked() tea.Cmd {
return func() tea.Msg {
resultSet := twc.tableReadControllers.ResultSet()
resultSet := twc.state.ResultSet()
markedItems := resultSet.MarkedItems()
if len(markedItems) == 0 {
@ -51,7 +272,7 @@ func (twc *TableWriteController) DeleteMarked() tea.Cmd {
return events.Error(err)
}
return twc.tableReadControllers.doScan(ctx, resultSet)
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query)
}
},
}

View file

@ -1,199 +1,365 @@
package controllers_test
import (
"testing"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/awstools/internal/dynamo-browse/controllers"
"github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo"
"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
"github.com/lmika/awstools/test/testdynamo"
"github.com/stretchr/testify/assert"
"testing"
)
func TestTableWriteController_ToggleReadWrite(t *testing.T) {
t.Skip("needs to be updated")
func TestTableWriteController_NewItem(t *testing.T) {
t.Run("should add an item with pk and sk set at the end of the result set", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
/*
twc, _, closeFn := setupController(t)
t.Cleanup(closeFn)
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
t.Run("should enabling read write if disabled", func(t *testing.T) {
ctx, uiCtx := testuictx.New(context.Background())
ctx = controllers.ContextWithState(ctx, controllers.State{
InReadWriteMode: false,
})
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
err := twc.ToggleReadWrite().Execute(ctx)
assert.NoError(t, err)
invokeCommand(t, readController.Init())
assert.Len(t, state.ResultSet().Items(), 3)
assert.Contains(t, uiCtx.Messages, controllers.SetReadWrite{NewValue: true})
})
// Prompt for keys
invokeCommandWithPrompts(t, writeController.NewItem(), "pk-value", "sk-value")
t.Run("should disable read write if enabled", func(t *testing.T) {
ctx, uiCtx := testuictx.New(context.Background())
ctx = controllers.ContextWithState(ctx, controllers.State{
InReadWriteMode: true,
})
newResultSet := state.ResultSet()
assert.Len(t, newResultSet.Items(), 4)
assert.Len(t, newResultSet.Items()[3], 2)
err := twc.ToggleReadWrite().Execute(ctx)
assert.NoError(t, err)
assert.Contains(t, uiCtx.Messages, controllers.SetReadWrite{NewValue: false})
})
*/
pk, _ := newResultSet.Items()[3].AttributeValueAsString("pk")
sk, _ := newResultSet.Items()[3].AttributeValueAsString("sk")
assert.Equal(t, "pk-value", pk)
assert.Equal(t, "sk-value", sk)
assert.True(t, newResultSet.IsNew(3))
assert.True(t, newResultSet.IsDirty(3))
})
}
func TestTableWriteController_Delete(t *testing.T) {
/*
t.Run("should delete selected item if in read/write mode is inactive", func(t *testing.T) {
twc, ctrls, closeFn := setupController(t)
t.Cleanup(closeFn)
func TestTableWriteController_SetStringValue(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName)
assert.NoError(t, err)
resultSet, err := ctrls.tableService.Scan(context.Background(), ti)
assert.NoError(t, err)
assert.Len(t, resultSet.Items, 3)
ctx, uiCtx := testuictx.New(context.Background())
ctx = controllers.ContextWithState(ctx, controllers.State{
ResultSet: resultSet,
SelectedItem: resultSet.Items[1],
InReadWriteMode: true,
})
op := twc.Delete()
// Should prompt first
err = op.Execute(ctx)
assert.NoError(t, err)
_ = uiCtx
*/
/*
promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput)
assert.True(t, ok)
// After prompt, continue to delete
err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "y"))
assert.NoError(t, err)
afterResultSet, err := ctrls.tableService.Scan(context.Background(), ti)
assert.NoError(t, err)
assert.Len(t, afterResultSet.Items, 2)
assert.Contains(t, afterResultSet.Items, resultSet.Items[0])
assert.NotContains(t, afterResultSet.Items, resultSet.Items[1])
assert.Contains(t, afterResultSet.Items, resultSet.Items[2])
*/
/*
})
t.Run("should not delete selected item if prompt is not y", func(t *testing.T) {
twc, ctrls, closeFn := setupController(t)
t.Cleanup(closeFn)
ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName)
assert.NoError(t, err)
resultSet, err := ctrls.tableService.Scan(context.Background(), ti)
assert.NoError(t, err)
assert.Len(t, resultSet.Items, 3)
ctx, uiCtx := testuictx.New(context.Background())
ctx = controllers.ContextWithState(ctx, controllers.State{
ResultSet: resultSet,
SelectedItem: resultSet.Items[1],
InReadWriteMode: true,
})
op := twc.Delete()
// Should prompt first
err = op.Execute(ctx)
assert.NoError(t, err)
_ = uiCtx
*/
/*
promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput)
assert.True(t, ok)
// After prompt, continue to delete
err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "n"))
assert.Error(t, err)
afterResultSet, err := ctrls.tableService.Scan(context.Background(), ti)
assert.NoError(t, err)
assert.Len(t, afterResultSet.Items, 3)
assert.Contains(t, afterResultSet.Items, resultSet.Items[0])
assert.Contains(t, afterResultSet.Items, resultSet.Items[1])
assert.Contains(t, afterResultSet.Items, resultSet.Items[2])
*/
/*
})
t.Run("should not delete if read/write mode is inactive", func(t *testing.T) {
tableWriteController, ctrls, closeFn := setupController(t)
t.Cleanup(closeFn)
ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName)
assert.NoError(t, err)
resultSet, err := ctrls.tableService.Scan(context.Background(), ti)
assert.NoError(t, err)
assert.Len(t, resultSet.Items, 3)
ctx, _ := testuictx.New(context.Background())
ctx = controllers.ContextWithState(ctx, controllers.State{
ResultSet: resultSet,
SelectedItem: resultSet.Items[1],
InReadWriteMode: false,
})
op := tableWriteController.Delete()
err = op.Execute(ctx)
assert.Error(t, err)
})
*/
}
type controller struct {
tableName string
tableService *tables.Service
}
func setupController(t *testing.T) (*controllers.TableWriteController, controller, func()) {
tableName := "table-write-controller-table"
client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData)
provider := dynamo.NewProvider(client)
tableService := tables.NewService(provider)
tableReadController := controllers.NewTableReadController(tableService, tableName)
tableWriteController := controllers.NewTableWriteController(tableService, tableReadController)
return tableWriteController, controller{
tableName: tableName,
tableService: tableService,
}, cleanupFn
service := tables.NewService(provider)
t.Run("should change the value of a string field if already present", func(t *testing.T) {
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", before)
assert.False(t, state.ResultSet().IsDirty(0))
invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value")
after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "a new value", after)
assert.True(t, state.ResultSet().IsDirty(0))
})
t.Run("should change the value of a string field within a map if already present", func(t *testing.T) {
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
invokeCommand(t, readController.Init())
beforeAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM)
beforeStreet := beforeAddress.Value["street"].(*types.AttributeValueMemberS).Value
assert.Equal(t, "Fake st.", beforeStreet)
assert.False(t, state.ResultSet().IsDirty(0))
invokeCommandWithPrompt(t, writeController.SetStringValue(0, "address.street"), "Fiction rd.")
afterAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM)
afterStreet := afterAddress.Value["street"].(*types.AttributeValueMemberS).Value
assert.Equal(t, "Fiction rd.", afterStreet)
assert.True(t, state.ResultSet().IsDirty(0))
})
}
var testData = testdynamo.TestData{
{
"pk": "abc",
"sk": "222",
"alpha": "This is another some value",
"beta": 1231,
},
{
"pk": "abc",
"sk": "111",
"alpha": "This is some value",
},
{
"pk": "bbb",
"sk": "131",
"beta": 2468,
"gamma": "foobar",
},
func TestTableWriteController_SetNumberValue(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
t.Run("should change the value of a number field if already present", func(t *testing.T) {
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("age")
assert.Equal(t, "23", before)
assert.False(t, state.ResultSet().IsDirty(0))
invokeCommandWithPrompt(t, writeController.SetNumberValue(0, "age"), "46")
after, _ := state.ResultSet().Items()[0].AttributeValueAsString("age")
assert.Equal(t, "46", after)
assert.True(t, state.ResultSet().IsDirty(0))
})
t.Run("should change the value of a number field within a map if already present", func(t *testing.T) {
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
invokeCommand(t, readController.Init())
beforeAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM)
beforeStreet := beforeAddress.Value["no"].(*types.AttributeValueMemberN).Value
assert.Equal(t, "123", beforeStreet)
assert.False(t, state.ResultSet().IsDirty(0))
invokeCommandWithPrompt(t, writeController.SetNumberValue(0, "address.no"), "456")
afterAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM)
afterStreet := afterAddress.Value["no"].(*types.AttributeValueMemberN).Value
assert.Equal(t, "456", afterStreet)
assert.True(t, state.ResultSet().IsDirty(0))
})
}
func TestTableWriteController_DeleteAttribute(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
t.Run("should delete top level attribute", func(t *testing.T) {
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("age")
assert.Equal(t, "23", before)
assert.False(t, state.ResultSet().IsDirty(0))
invokeCommand(t, writeController.DeleteAttribute(0, "age"))
_, hasAge := state.ResultSet().Items()[0]["age"]
assert.False(t, hasAge)
})
t.Run("should delete attribute of map", func(t *testing.T) {
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
invokeCommand(t, readController.Init())
beforeAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM)
beforeStreet := beforeAddress.Value["no"].(*types.AttributeValueMemberN).Value
assert.Equal(t, "123", beforeStreet)
assert.False(t, state.ResultSet().IsDirty(0))
invokeCommand(t, writeController.DeleteAttribute(0, "address.no"))
afterAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM)
_, hasStreet := afterAddress.Value["no"]
assert.False(t, hasStreet)
})
}
func TestTableWriteController_PutItem(t *testing.T) {
t.Run("should put the selected item if dirty", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
// Read the table
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", before)
assert.False(t, state.ResultSet().IsDirty(0))
// Modify the item and put it
invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value")
invokeCommandWithPrompt(t, writeController.PutItem(0), "y")
// Rescan the table
invokeCommand(t, readController.Rescan())
after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "a new value", after)
assert.False(t, state.ResultSet().IsDirty(0))
})
t.Run("should not put the selected item if user does not confirm", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
// Read the table
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", before)
assert.False(t, state.ResultSet().IsDirty(0))
// Modify the item but do not put it
invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value")
invokeCommandWithPrompt(t, writeController.PutItem(0), "n")
current, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "a new value", current)
assert.True(t, state.ResultSet().IsDirty(0))
// Rescan the table to confirm item is not modified
invokeCommand(t, readController.Rescan())
after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", after)
assert.False(t, state.ResultSet().IsDirty(0))
})
t.Run("should not put the selected item if not dirty", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
// Read the table
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", before)
assert.False(t, state.ResultSet().IsDirty(0))
invokeCommandExpectingError(t, writeController.PutItem(0))
})
}
func TestTableWriteController_TouchItem(t *testing.T) {
t.Run("should put the selected item if unmodified", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
// Read the table
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", before)
assert.False(t, state.ResultSet().IsDirty(0))
// Modify the item and put it
invokeCommandWithPrompt(t, writeController.TouchItem(0), "y")
// Rescan the table
invokeCommand(t, readController.Rescan())
after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", after)
assert.False(t, state.ResultSet().IsDirty(0))
})
t.Run("should not put the selected item if modified", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
// Read the table
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", before)
assert.False(t, state.ResultSet().IsDirty(0))
// Modify the item and put it
invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value")
invokeCommandExpectingError(t, writeController.TouchItem(0))
})
}
func TestTableWriteController_NoisyTouchItem(t *testing.T) {
t.Run("should delete and put the selected item if unmodified", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
// Read the table
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", before)
assert.False(t, state.ResultSet().IsDirty(0))
// Modify the item and put it
invokeCommandWithPrompt(t, writeController.NoisyTouchItem(0), "y")
// Rescan the table
invokeCommand(t, readController.Rescan())
after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", after)
assert.False(t, state.ResultSet().IsDirty(0))
})
t.Run("should not put the selected item if modified", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
service := tables.NewService(provider)
state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController)
// Read the table
invokeCommand(t, readController.Init())
before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "This is some value", before)
assert.False(t, state.ResultSet().IsDirty(0))
// Modify the item and put it
invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value")
invokeCommandExpectingError(t, writeController.NoisyTouchItem(0))
})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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}
}

View file

@ -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)
}

View file

@ -1,6 +1,9 @@
package models
import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/awstools/internal/dynamo-browse/models/itemrender"
)
type Item map[string]types.AttributeValue
@ -25,6 +28,10 @@ func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue {
return itemKey
}
func (i Item) AttributeValueAsString(k string) (string, bool) {
return attributeToString(i[k])
func (i Item) AttributeValueAsString(key string) (string, bool) {
return attributeToString(i[key])
}
func (i Item) Renderer(key string) itemrender.Renderer {
return itemrender.ToRenderer(i[key])
}

View file

@ -1,15 +1,27 @@
package models
import "sort"
type ResultSet struct {
TableInfo *TableInfo
Columns []string
TableInfo *TableInfo
Query Queryable
//Columns []string
items []Item
attributes []ItemAttribute
columns []string
}
type Queryable interface {
String() string
Plan(tableInfo *TableInfo) (*QueryExecutionPlan, error)
}
type ItemAttribute struct {
Marked bool
Hidden bool
Dirty bool
New bool
}
func (rs *ResultSet) Items() []Item {
@ -21,6 +33,11 @@ func (rs *ResultSet) SetItems(items []Item) {
rs.attributes = make([]ItemAttribute, len(items))
}
func (rs *ResultSet) AddNewItem(item Item, attrs ItemAttribute) {
rs.items = append(rs.items, item)
rs.attributes = append(rs.attributes, attrs)
}
func (rs *ResultSet) SetMark(idx int, marked bool) {
rs.attributes[idx].Marked = marked
}
@ -29,6 +46,14 @@ func (rs *ResultSet) SetHidden(idx int, hidden bool) {
rs.attributes[idx].Hidden = hidden
}
func (rs *ResultSet) SetDirty(idx int, dirty bool) {
rs.attributes[idx].Dirty = dirty
}
func (rs *ResultSet) SetNew(idx int, isNew bool) {
rs.attributes[idx].New = isNew
}
func (rs *ResultSet) Marked(idx int) bool {
return rs.attributes[idx].Marked
}
@ -37,6 +62,14 @@ func (rs *ResultSet) Hidden(idx int) bool {
return rs.attributes[idx].Hidden
}
func (rs *ResultSet) IsDirty(idx int) bool {
return rs.attributes[idx].Dirty
}
func (rs *ResultSet) IsNew(idx int) bool {
return rs.attributes[idx].New
}
func (rs *ResultSet) MarkedItems() []Item {
items := make([]Item, 0)
for i, itemAttr := range rs.attributes {
@ -46,3 +79,46 @@ func (rs *ResultSet) MarkedItems() []Item {
}
return items
}
func (rs *ResultSet) Columns() []string {
if rs.columns == nil {
rs.RefreshColumns()
}
return rs.columns
}
func (rs *ResultSet) RefreshColumns() {
seenColumns := make(map[string]int)
seenColumns[rs.TableInfo.Keys.PartitionKey] = 0
if rs.TableInfo.Keys.SortKey != "" {
seenColumns[rs.TableInfo.Keys.SortKey] = 1
}
for _, definedAttribute := range rs.TableInfo.DefinedAttributes {
if _, seen := seenColumns[definedAttribute]; !seen {
seenColumns[definedAttribute] = len(seenColumns)
}
}
otherColsRank := len(seenColumns)
for _, result := range rs.items {
for k := range result {
if _, isSeen := seenColumns[k]; !isSeen {
seenColumns[k] = otherColsRank
}
}
}
columns := make([]string, 0, len(seenColumns))
for k := range seenColumns {
columns = append(columns, k)
}
sort.Slice(columns, func(i, j int) bool {
if seenColumns[columns[i]] == seenColumns[columns[j]] {
return columns[i] < columns[j]
}
return seenColumns[columns[i]] < seenColumns[columns[j]]
})
rs.columns = columns
}

View file

@ -0,0 +1,8 @@
package models
import "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
type QueryExecutionPlan struct {
CanQuery bool
Expression expression.Expression
}

View file

@ -0,0 +1,32 @@
package queryexpr
import (
"github.com/alecthomas/participle/v2"
"github.com/pkg/errors"
)
type astExpr struct {
Equality *astBinOp `parser:"@@"`
}
type astBinOp struct {
Name string `parser:"@Ident"`
Op string `parser:"@('^' '=' | '=')"`
Value *astLiteralValue `parser:"@@"`
}
type astLiteralValue struct {
StringVal string `parser:"@String"`
}
var parser = participle.MustBuild(&astExpr{})
func Parse(expr string) (*QueryExpr, error) {
var ast astExpr
if err := parser.ParseString("expr", expr, &ast); err != nil {
return nil, errors.Wrapf(err, "cannot parse expression: '%v'", expr)
}
return &QueryExpr{ast: &ast}, nil
}

View file

@ -0,0 +1,52 @@
package queryexpr
import (
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"github.com/pkg/errors"
)
func (a *astExpr) calcQuery(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) {
return a.Equality.calcQuery(tableInfo)
}
func (a *astBinOp) calcQuery(info *models.TableInfo) (*models.QueryExecutionPlan, error) {
// TODO: check if can be a query
cb, err := a.calcQueryForScan(info)
if err != nil {
return nil, err
}
builder := expression.NewBuilder()
builder = builder.WithFilter(cb)
expr, err := builder.Build()
if err != nil {
return nil, err
}
return &models.QueryExecutionPlan{
CanQuery: false,
Expression: expr,
}, nil
}
func (a *astBinOp) calcQueryForScan(info *models.TableInfo) (expression.ConditionBuilder, error) {
v, err := a.Value.goValue()
if err != nil {
return expression.ConditionBuilder{}, err
}
switch a.Op {
case "=":
return expression.Name(a.Name).Equal(expression.Value(v)), nil
case "^=":
strValue, isStrValue := v.(string)
if !isStrValue {
return expression.ConditionBuilder{}, errors.New("operand '^=' must be string")
}
return expression.Name(a.Name).BeginsWith(strValue), nil
}
return expression.ConditionBuilder{}, errors.Errorf("unrecognised operator: %v", a.Op)
}

View file

@ -0,0 +1,15 @@
package queryexpr
import "github.com/lmika/awstools/internal/dynamo-browse/models"
type QueryExpr struct {
ast *astExpr
}
func (md *QueryExpr) Plan(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) {
return md.ast.calcQuery(tableInfo)
}
func (md *QueryExpr) String() string {
return md.ast.String()
}

View file

@ -0,0 +1,47 @@
package queryexpr_test
import (
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr"
"testing"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"github.com/stretchr/testify/assert"
)
func TestModExpr_Query(t *testing.T) {
tableInfo := &models.TableInfo{
Name: "test",
Keys: models.KeyAttribute{
PartitionKey: "pk",
SortKey: "sk",
},
}
t.Run("perform query when request pk is fixed", func(t *testing.T) {
modExpr, err := queryexpr.Parse(`pk="prefix"`)
assert.NoError(t, err)
plan, err := modExpr.Plan(tableInfo)
assert.NoError(t, err)
assert.False(t, plan.CanQuery)
assert.Equal(t, "#0 = :0", aws.ToString(plan.Expression.Filter()))
assert.Equal(t, "pk", plan.Expression.Names()["#0"])
assert.Equal(t, "prefix", plan.Expression.Values()[":0"].(*types.AttributeValueMemberS).Value)
})
t.Run("perform scan when request pk prefix", func(t *testing.T) {
modExpr, err := queryexpr.Parse(`pk^="prefix"`) // TODO: fix this so that '^ =' is invalid
assert.NoError(t, err)
plan, err := modExpr.Plan(tableInfo)
assert.NoError(t, err)
assert.False(t, plan.CanQuery)
assert.Equal(t, "begins_with (#0, :0)", aws.ToString(plan.Expression.Filter()))
assert.Equal(t, "pk", plan.Expression.Names()["#0"])
assert.Equal(t, "prefix", plan.Expression.Values()[":0"].(*types.AttributeValueMemberS).Value)
})
}

View file

@ -0,0 +1,13 @@
package queryexpr
func (a *astExpr) String() string {
return a.Equality.String()
}
func (a *astBinOp) String() string {
return a.Name + a.Op + a.Value.String()
}
func (a *astLiteralValue) String() string {
return a.StringVal
}

View file

@ -0,0 +1,24 @@
package queryexpr
import (
"strconv"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/pkg/errors"
)
func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) {
s, err := strconv.Unquote(a.StringVal)
if err != nil {
return nil, errors.Wrap(err, "cannot unquote string")
}
return &types.AttributeValueMemberS{Value: s}, nil
}
func (a *astLiteralValue) goValue() (any, error) {
s, err := strconv.Unquote(a.StringVal)
if err != nil {
return nil, errors.Wrap(err, "cannot unquote string")
}
return s, nil
}

View file

@ -2,8 +2,8 @@ package dynamo
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/awstools/internal/dynamo-browse/models"
@ -64,17 +64,34 @@ func NewProvider(client *dynamodb.Client) *Provider {
return &Provider{client: client}
}
func (p *Provider) ScanItems(ctx context.Context, tableName string) ([]models.Item, error) {
res, err := p.client.Scan(ctx, &dynamodb.ScanInput{
func (p *Provider) ScanItems(ctx context.Context, tableName string, filterExpr *expression.Expression, maxItems int) ([]models.Item, error) {
input := &dynamodb.ScanInput{
TableName: aws.String(tableName),
})
if err != nil {
return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName)
Limit: aws.Int32(int32(maxItems)),
}
if filterExpr != nil {
input.FilterExpression = filterExpr.Filter()
input.ExpressionAttributeNames = filterExpr.Names()
input.ExpressionAttributeValues = filterExpr.Values()
}
items := make([]models.Item, len(res.Items))
for i, itm := range res.Items {
items[i] = itm
paginator := dynamodb.NewScanPaginator(p.client, input)
items := make([]models.Item, 0)
outer:
for paginator.HasMorePages() {
res, err := paginator.NextPage(ctx)
if err != nil {
return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName)
}
for _, itm := range res.Items {
items = append(items, itm)
if len(items) >= maxItems {
break outer
}
}
}
return items, nil

View file

@ -11,38 +11,38 @@ import (
)
func TestProvider_ScanItems(t *testing.T) {
tableName := "provider-scanimages-test-table"
tableName := "test-table"
client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData)
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
t.Run("should return scanned items from the table", func(t *testing.T) {
ctx := context.Background()
items, err := provider.ScanItems(ctx, tableName)
items, err := provider.ScanItems(ctx, tableName, nil, 100)
assert.NoError(t, err)
assert.Len(t, items, 3)
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[1]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[0]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[1]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[2]))
})
t.Run("should return error if table name does not exist", func(t *testing.T) {
ctx := context.Background()
items, err := provider.ScanItems(ctx, "does-not-exist")
items, err := provider.ScanItems(ctx, "does-not-exist", nil, 100)
assert.Error(t, err)
assert.Nil(t, items)
})
}
func TestProvider_DeleteItem(t *testing.T) {
tableName := "provider-deleteitem-test-table"
tableName := "test-table"
t.Run("should delete item if exists in table", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData)
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
@ -53,18 +53,18 @@ func TestProvider_DeleteItem(t *testing.T) {
"sk": &types.AttributeValueMemberS{Value: "222"},
})
items, err := provider.ScanItems(ctx, tableName)
items, err := provider.ScanItems(ctx, tableName, nil, 100)
assert.NoError(t, err)
assert.Len(t, items, 2)
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2]))
assert.NotContains(t, items, testdynamo.TestRecordAsItem(t, testData[1]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[0]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[2]))
assert.NotContains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[1]))
})
t.Run("should do nothing if key does not exist", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData)
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
@ -75,44 +75,49 @@ func TestProvider_DeleteItem(t *testing.T) {
"sk": &types.AttributeValueMemberS{Value: "999"},
})
items, err := provider.ScanItems(ctx, tableName)
items, err := provider.ScanItems(ctx, tableName, nil, 100)
assert.NoError(t, err)
assert.Len(t, items, 3)
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[1]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[0]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[1]))
assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[2]))
})
t.Run("should return error if table name does not exist", func(t *testing.T) {
client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData)
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
ctx := context.Background()
items, err := provider.ScanItems(ctx, "does-not-exist")
items, err := provider.ScanItems(ctx, "does-not-exist", nil, 100)
assert.Error(t, err)
assert.Nil(t, items)
})
}
var testData = testdynamo.TestData{
var testData = []testdynamo.TestData{
{
"pk": "abc",
"sk": "111",
"alpha": "This is some value",
},
{
"pk": "abc",
"sk": "222",
"alpha": "This is another some value",
"beta": 1231,
},
{
"pk": "bbb",
"sk": "131",
"beta": 2468,
"gamma": "foobar",
TableName: "test-table",
Data: []map[string]interface{}{
{
"pk": "abc",
"sk": "111",
"alpha": "This is some value",
},
{
"pk": "abc",
"sk": "222",
"alpha": "This is another some value",
"beta": 1231,
},
{
"pk": "bbb",
"sk": "131",
"beta": 2468,
"gamma": "foobar",
},
},
},
}

View file

@ -2,6 +2,7 @@ package tables
import (
"context"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/awstools/internal/dynamo-browse/models"
@ -10,7 +11,7 @@ import (
type TableProvider interface {
ListTables(ctx context.Context) ([]string, error)
DescribeTable(ctx context.Context, tableName string) (*models.TableInfo, error)
ScanItems(ctx context.Context, tableName string) ([]models.Item, error)
ScanItems(ctx context.Context, tableName string, filterExpr *expression.Expression, maxItems int) ([]models.Item, error)
DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error
PutItem(ctx context.Context, name string, item models.Item) error
}

View file

@ -2,7 +2,7 @@ package tables
import (
"context"
"sort"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"strings"
"github.com/lmika/awstools/internal/dynamo-browse/models"
@ -28,51 +28,73 @@ func (s *Service) Describe(ctx context.Context, table string) (*models.TableInfo
}
func (s *Service) Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) {
results, err := s.provider.ScanItems(ctx, tableInfo.Name)
return s.doScan(ctx, tableInfo, nil)
}
func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, expr models.Queryable) (*models.ResultSet, error) {
var filterExpr *expression.Expression
if expr != nil {
plan, err := expr.Plan(tableInfo)
if err != nil {
return nil, err
}
// TEMP
if plan.CanQuery {
return nil, errors.Errorf("queries not yet supported")
}
filterExpr = &plan.Expression
}
results, err := s.provider.ScanItems(ctx, tableInfo.Name, filterExpr, 1000)
if err != nil {
return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name)
}
// Get the columns
seenColumns := make(map[string]int)
seenColumns[tableInfo.Keys.PartitionKey] = 0
if tableInfo.Keys.SortKey != "" {
seenColumns[tableInfo.Keys.SortKey] = 1
}
for _, definedAttribute := range tableInfo.DefinedAttributes {
if _, seen := seenColumns[definedAttribute]; !seen {
seenColumns[definedAttribute] = len(seenColumns)
}
}
otherColsRank := len(seenColumns)
for _, result := range results {
for k := range result {
if _, isSeen := seenColumns[k]; !isSeen {
seenColumns[k] = otherColsRank
}
}
}
columns := make([]string, 0, len(seenColumns))
for k := range seenColumns {
columns = append(columns, k)
}
sort.Slice(columns, func(i, j int) bool {
if seenColumns[columns[i]] == seenColumns[columns[j]] {
return columns[i] < columns[j]
}
return seenColumns[columns[i]] < seenColumns[columns[j]]
})
//seenColumns := make(map[string]int)
//seenColumns[tableInfo.Keys.PartitionKey] = 0
//if tableInfo.Keys.SortKey != "" {
// seenColumns[tableInfo.Keys.SortKey] = 1
//}
//
//for _, definedAttribute := range tableInfo.DefinedAttributes {
// if _, seen := seenColumns[definedAttribute]; !seen {
// seenColumns[definedAttribute] = len(seenColumns)
// }
//}
//
//otherColsRank := len(seenColumns)
//for _, result := range results {
// for k := range result {
// if _, isSeen := seenColumns[k]; !isSeen {
// seenColumns[k] = otherColsRank
// }
// }
//}
//
//columns := make([]string, 0, len(seenColumns))
//for k := range seenColumns {
// columns = append(columns, k)
//}
//sort.Slice(columns, func(i, j int) bool {
// if seenColumns[columns[i]] == seenColumns[columns[j]] {
// return columns[i] < columns[j]
// }
// return seenColumns[columns[i]] < seenColumns[columns[j]]
//})
models.Sort(results, tableInfo)
resultSet := &models.ResultSet{
TableInfo: tableInfo,
Columns: columns,
Query: expr,
//Columns: columns,
}
resultSet.SetItems(results)
resultSet.RefreshColumns()
return resultSet, nil
}
@ -81,6 +103,17 @@ func (s *Service) Put(ctx context.Context, tableInfo *models.TableInfo, item mod
return s.provider.PutItem(ctx, tableInfo.Name, item)
}
func (s *Service) PutItemAt(ctx context.Context, resultSet *models.ResultSet, index int) error {
item := resultSet.Items()[index]
if err := s.provider.PutItem(ctx, resultSet.TableInfo.Name, item); err != nil {
return err
}
resultSet.SetDirty(index, false)
resultSet.SetNew(index, false)
return nil
}
func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items []models.Item) error {
for _, item := range items {
if err := s.provider.DeleteItem(ctx, tableInfo.Name, item.KeyValue(tableInfo)); err != nil {
@ -90,6 +123,10 @@ func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items
return nil
}
func (s *Service) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, expr models.Queryable) (*models.ResultSet, error) {
return s.doScan(ctx, tableInfo, expr)
}
// TODO: move into a new service
func (s *Service) Filter(resultSet *models.ResultSet, filter string) *models.ResultSet {
for i, item := range resultSet.Items() {

View file

@ -11,9 +11,9 @@ import (
)
func TestService_Describe(t *testing.T) {
tableName := "service-describe-table"
tableName := "service-test-data"
client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData)
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
@ -33,9 +33,9 @@ func TestService_Describe(t *testing.T) {
}
func TestService_Scan(t *testing.T) {
tableName := "service-scan-test-table"
tableName := "service-test-data"
client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData)
client, cleanupFn := testdynamo.SetupTestTable(t, testData)
defer cleanupFn()
provider := dynamo.NewProvider(client)
@ -51,29 +51,34 @@ func TestService_Scan(t *testing.T) {
// Hash first, then range, then columns in alphabetic order
assert.Equal(t, rs.TableInfo, ti)
assert.Equal(t, rs.Columns, []string{"pk", "sk", "alpha", "beta", "gamma"})
assert.Equal(t, rs.Columns(), []string{"pk", "sk", "alpha", "beta", "gamma"})
//assert.Equal(t, rs.Items[0], testdynamo.TestRecordAsItem(t, testData[1]))
//assert.Equal(t, rs.Items[1], testdynamo.TestRecordAsItem(t, testData[0]))
//assert.Equal(t, rs.Items[2], testdynamo.TestRecordAsItem(t, testData[2]))
})
}
var testData = testdynamo.TestData{
var testData = []testdynamo.TestData{
{
"pk": "abc",
"sk": "222",
"alpha": "This is another some value",
"beta": 1231,
},
{
"pk": "abc",
"sk": "111",
"alpha": "This is some value",
},
{
"pk": "bbb",
"sk": "131",
"beta": 2468,
"gamma": "foobar",
TableName: "service-test-data",
Data: []map[string]interface{}{
{
"pk": "abc",
"sk": "222",
"alpha": "This is another some value",
"beta": 1231,
},
{
"pk": "abc",
"sk": "111",
"alpha": "This is some value",
},
{
"pk": "bbb",
"sk": "131",
"beta": 2468,
"gamma": "foobar",
},
},
},
}

View file

@ -3,13 +3,17 @@ package ui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/common/ui/commandctrl"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/dynamo-browse/controllers"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemedit"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dialogprompt"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamotableview"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/tableselect"
"github.com/pkg/errors"
)
type Model struct {
@ -25,13 +29,16 @@ type Model struct {
}
func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteController, cc *commandctrl.CommandController) Model {
dtv := dynamotableview.New()
div := dynamoitemview.New()
uiStyles := styles.DefaultStyles
dtv := dynamotableview.New(uiStyles)
div := dynamoitemview.New(uiStyles)
mainView := layout.NewVBox(layout.LastChildFixedAt(17), dtv, div)
itemEdit := dynamoitemedit.NewModel(mainView)
statusAndPrompt := statusandprompt.New(itemEdit, "")
tableSelect := tableselect.New(statusAndPrompt)
statusAndPrompt := statusandprompt.New(itemEdit, "", uiStyles.StatusAndPrompt)
dialogPrompt := dialogprompt.New(statusAndPrompt)
tableSelect := tableselect.New(dialogPrompt, uiStyles)
cc.AddCommands(&commandctrl.CommandContext{
Commands: map[string]commandctrl.Command{
@ -43,8 +50,45 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon
return rc.ScanTable(args[0])
}
},
"export": func(args []string) tea.Cmd {
if len(args) == 0 {
return events.SetError(errors.New("expected filename"))
}
return rc.ExportCSV(args[0])
},
"unmark": commandctrl.NoArgCommand(rc.Unmark()),
"delete": commandctrl.NoArgCommand(wc.DeleteMarked()),
// TEMP
"new-item": commandctrl.NoArgCommand(wc.NewItem()),
"set-s": func(args []string) tea.Cmd {
if len(args) == 0 {
return events.SetError(errors.New("expected field"))
}
return wc.SetStringValue(dtv.SelectedItemIndex(), args[0])
},
"set-n": func(args []string) tea.Cmd {
if len(args) == 0 {
return events.SetError(errors.New("expected field"))
}
return wc.SetNumberValue(dtv.SelectedItemIndex(), args[0])
},
"del-attr": func(args []string) tea.Cmd {
if len(args) == 0 {
return events.SetError(errors.New("expected field"))
}
return wc.DeleteAttribute(dtv.SelectedItemIndex(), args[0])
},
"put": func(args []string) tea.Cmd {
return wc.PutItem(dtv.SelectedItemIndex())
},
"touch": func(args []string) tea.Cmd {
return wc.TouchItem(dtv.SelectedItemIndex())
},
"noisy-touch": func(args []string) tea.Cmd {
return wc.NoisyTouchItem(dtv.SelectedItemIndex())
},
},
})
@ -77,8 +121,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, m.tableWriteController.ToggleMark(idx)
}
case "s":
case "R":
return m, m.tableReadController.Rescan()
case "?":
return m, m.tableReadController.PromptForQuery()
case "/":
return m, m.tableReadController.Filter()
case "e":

View file

@ -0,0 +1,33 @@
package dialogprompt
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
)
var style = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("63"))
type dialogModel struct {
w, h int
borderStyle lipgloss.Style
}
func (d *dialogModel) Init() tea.Cmd {
return nil
}
func (d *dialogModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return d, nil
}
func (d *dialogModel) View() string {
return style.Width(d.w).Height(d.h).Render("Hello this is a test of some content")
}
func (d *dialogModel) Resize(w, h int) layout.ResizingModel {
d.w, d.h = w-2, h-2
return d
}

View file

@ -0,0 +1,38 @@
package dialogprompt
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
)
type Model struct {
compositor *layout.Compositor
}
func New(model layout.ResizingModel) *Model {
m := &Model{
compositor: layout.NewCompositor(model),
}
// TEMP
//m.compositor.SetOverlay(&dialogModel{}, 5, 5, 30, 12)
return m
}
func (m *Model) Init() tea.Cmd {
return m.compositor.Init()
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
newModel, cmd := m.compositor.Update(msg)
m.compositor = newModel.(*layout.Compositor)
return m, cmd
}
func (m *Model) View() string {
return m.compositor.View()
}
func (m *Model) Resize(w, h int) layout.ResizingModel {
m.compositor = m.compositor.Resize(w, h).(*layout.Compositor)
return m
}

View file

@ -2,10 +2,12 @@ package dynamoitemview
import (
"fmt"
"github.com/lmika/awstools/internal/dynamo-browse/models/itemrender"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles"
"io"
"strings"
"text/tabwriter"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@ -16,9 +18,14 @@ import (
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
fieldTypeStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#2B800C", Dark: "#73C653"})
metaInfoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
)
type Model struct {
@ -32,9 +39,9 @@ type Model struct {
selectedItem models.Item
}
func New() *Model {
func New(uiStyles styles.Styles) *Model {
return &Model{
frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle),
frameTitle: frame.NewFrameTitle("Item", false, uiStyles.Frames),
viewport: viewport.New(100, 100),
}
}
@ -82,16 +89,19 @@ func (m *Model) updateViewportToSelectedMessage() {
viewportContent := &strings.Builder{}
tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0)
for _, colName := range m.currentResultSet.Columns {
switch colVal := m.selectedItem[colName].(type) {
case nil:
break
case *types.AttributeValueMemberS:
fmt.Fprintf(tabWriter, "%v\tS\t%s\n", colName, colVal.Value)
case *types.AttributeValueMemberN:
fmt.Fprintf(tabWriter, "%v\tN\t%s\n", colName, colVal.Value)
default:
fmt.Fprintf(tabWriter, "%v\t?\t%s\n", colName, "(other)")
seenColumns := make(map[string]struct{})
for _, colName := range m.currentResultSet.Columns() {
seenColumns[colName] = struct{}{}
if r := m.selectedItem.Renderer(colName); r != nil {
m.renderItem(tabWriter, "", colName, r)
}
}
for k, _ := range m.selectedItem {
if _, seen := seenColumns[k]; !seen {
if r := m.selectedItem.Renderer(k); r != nil {
m.renderItem(tabWriter, "", k, r)
}
}
}
@ -100,3 +110,13 @@ func (m *Model) updateViewportToSelectedMessage() {
m.viewport.Height = m.h - m.frameTitle.HeaderHeight()
m.viewport.SetContent(viewportContent.String())
}
func (m *Model) renderItem(w io.Writer, prefix string, name string, r itemrender.Renderer) {
fmt.Fprintf(w, "%s%v\t%s\t%s%s\n",
prefix, name, fieldTypeStyle.Render(r.TypeName()), r.StringValue(), metaInfoStyle.Render(r.MetaInfo()))
if subitems := r.SubItems(); len(subitems) > 0 {
for _, si := range subitems {
m.renderItem(w, prefix+" ", si.Key, si.Value)
}
}
}

View file

@ -1,7 +1,7 @@
package dynamotableview
import (
table "github.com/calyptia/go-bubble-table"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/controllers"
@ -9,6 +9,8 @@ import (
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles"
table "github.com/lmika/go-bubble-table"
)
var (
@ -18,26 +20,61 @@ var (
Background(lipgloss.Color("#4479ff"))
)
type KeyBinding struct {
MoveUp key.Binding
MoveDown key.Binding
PageUp key.Binding
PageDown key.Binding
Home key.Binding
End key.Binding
ColLeft key.Binding
ColRight key.Binding
}
type Model struct {
frameTitle frame.FrameTitle
table table.Model
w, h int
keyBinding KeyBinding
// model state
colOffset int
rows []table.Row
resultSet *models.ResultSet
}
func New() *Model {
tbl := table.New([]string{"pk", "sk"}, 100, 100)
type columnModel struct {
m *Model
}
func (cm columnModel) Len() int {
return len(cm.m.resultSet.Columns()[cm.m.colOffset:])
}
func (cm columnModel) Header(index int) string {
return cm.m.resultSet.Columns()[cm.m.colOffset+index]
}
func New(uiStyles styles.Styles) *Model {
tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100)
rows := make([]table.Row, 0)
tbl.SetRows(rows)
frameTitle := frame.NewFrameTitle("No table", true, activeHeaderStyle)
frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames)
return &Model{
frameTitle: frameTitle,
table: tbl,
keyBinding: KeyBinding{
MoveUp: key.NewBinding(key.WithKeys("i", "up")),
MoveDown: key.NewBinding(key.WithKeys("k", "down")),
PageUp: key.NewBinding(key.WithKeys("I", "pgup")),
PageDown: key.NewBinding(key.WithKeys("K", "pgdown")),
Home: key.NewBinding(key.WithKeys("I", "home")),
End: key.NewBinding(key.WithKeys("K", "end")),
ColLeft: key.NewBinding(key.WithKeys("j", "left")),
ColRight: key.NewBinding(key.WithKeys("l", "right")),
},
}
}
@ -52,26 +89,49 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateTable()
return m, m.postSelectedItemChanged
case tea.KeyMsg:
switch msg.String() {
switch {
// Table nav
case "i", "up":
case key.Matches(msg, m.keyBinding.MoveUp):
m.table.GoUp()
return m, m.postSelectedItemChanged
case "k", "down":
case key.Matches(msg, m.keyBinding.MoveDown):
m.table.GoDown()
return m, m.postSelectedItemChanged
case "I", "pgup":
case key.Matches(msg, m.keyBinding.PageUp):
m.table.GoPageUp()
return m, m.postSelectedItemChanged
case "K", "pgdn":
case key.Matches(msg, m.keyBinding.PageDown):
m.table.GoPageDown()
return m, m.postSelectedItemChanged
case key.Matches(msg, m.keyBinding.Home):
m.table.GoTop()
return m, m.postSelectedItemChanged
case key.Matches(msg, m.keyBinding.End):
m.table.GoBottom()
return m, m.postSelectedItemChanged
case key.Matches(msg, m.keyBinding.ColLeft):
m.setLeftmostDisplayedColumn(m.colOffset - 1)
return m, nil
case key.Matches(msg, m.keyBinding.ColRight):
m.setLeftmostDisplayedColumn(m.colOffset + 1)
return m, nil
}
}
return m, nil
}
func (m *Model) setLeftmostDisplayedColumn(newCol int) {
if newCol < 0 {
m.colOffset = 0
} else if newCol >= len(m.resultSet.Columns()) {
m.colOffset = len(m.resultSet.Columns()) - 1
} else {
m.colOffset = newCol
}
m.table.UpdateView()
}
func (m *Model) View() string {
return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View())
}
@ -85,23 +145,32 @@ func (m *Model) Resize(w, h int) layout.ResizingModel {
}
func (m *Model) updateTable() {
m.colOffset = 0
m.frameTitle.SetTitle("Table: " + m.resultSet.TableInfo.Name)
m.rebuildTable()
}
func (m *Model) rebuildTable() {
resultSet := m.resultSet
m.frameTitle.SetTitle("Table: " + resultSet.TableInfo.Name)
newTbl := table.New(resultSet.Columns, m.w, m.h-m.frameTitle.HeaderHeight())
newTbl := table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight())
newRows := make([]table.Row, 0)
for i, r := range resultSet.Items() {
if resultSet.Hidden(i) {
continue
}
newRows = append(newRows, itemTableRow{resultSet: resultSet, itemIndex: i, item: r})
newRows = append(newRows, itemTableRow{
model: m,
resultSet: resultSet,
itemIndex: i,
item: r,
})
}
m.rows = newRows
newTbl.SetRows(newRows)
m.table = newTbl
}
@ -135,5 +204,6 @@ func (m *Model) postSelectedItemChanged() tea.Msg {
}
func (m *Model) Refresh() {
m.table.SetRows(m.rows)
}

View file

@ -6,17 +6,24 @@ import (
"io"
"strings"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
table "github.com/calyptia/go-bubble-table"
"github.com/lmika/awstools/internal/dynamo-browse/models"
table "github.com/lmika/go-bubble-table"
)
var (
markedRowStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#e1e1e1"))
Background(lipgloss.AdaptiveColor{Light: "#e1e1e1", Dark: "#414141"})
dirtyRowStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#e13131"))
newRowStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#31e131"))
metaInfoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
)
type itemTableRow struct {
model *Model
resultSet *models.ResultSet
itemIndex int
item models.Item
@ -24,33 +31,39 @@ type itemTableRow struct {
func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) {
isMarked := mtr.resultSet.Marked(mtr.itemIndex)
isDirty := mtr.resultSet.IsDirty(mtr.itemIndex)
isNew := mtr.resultSet.IsNew(mtr.itemIndex)
var style lipgloss.Style
if index == model.Cursor() {
style = model.Styles.SelectedRow
}
if isMarked {
style = style.Copy().Inherit(markedRowStyle)
}
if isNew {
style = style.Copy().Inherit(newRowStyle)
} else if isDirty {
style = style.Copy().Inherit(dirtyRowStyle)
}
metaInfoStyle := style.Copy().Inherit(metaInfoStyle)
sb := strings.Builder{}
for i, colName := range mtr.resultSet.Columns {
for i, colName := range mtr.resultSet.Columns()[mtr.model.colOffset:] {
if i > 0 {
sb.WriteString("\t")
sb.WriteString(style.Render("\t"))
}
switch colVal := mtr.item[colName].(type) {
case nil:
sb.WriteString("(nil)")
case *types.AttributeValueMemberS:
sb.WriteString(colVal.Value)
case *types.AttributeValueMemberN:
sb.WriteString(colVal.Value)
default:
sb.WriteString("(other)")
if r := mtr.item.Renderer(colName); r != nil {
sb.WriteString(style.Render(r.StringValue()))
if mi := r.MetaInfo(); mi != "" {
sb.WriteString(metaInfoStyle.Render(mi))
}
} else {
sb.WriteString(metaInfoStyle.Render("~"))
}
}
if index == model.Cursor() {
style := model.Styles.SelectedRow
if isMarked {
style = style.Copy().Inherit(markedRowStyle)
}
fmt.Fprintln(w, style.Render(sb.String()))
} else if isMarked {
fmt.Fprintln(w, markedRowStyle.Render(sb.String()))
} else {
fmt.Fprintln(w, sb.String())
}
fmt.Fprintln(w, sb.String())
}

View file

@ -15,14 +15,19 @@ var (
// Frame is a frame that appears in the
type FrameTitle struct {
header string
active bool
activeStyle lipgloss.Style
width int
header string
active bool
style Style
width int
}
func NewFrameTitle(header string, active bool, activeStyle lipgloss.Style) FrameTitle {
return FrameTitle{header, active, activeStyle, 0}
type Style struct {
ActiveTitle lipgloss.Style
InactiveTitle lipgloss.Style
}
func NewFrameTitle(header string, active bool, style Style) FrameTitle {
return FrameTitle{header, active, style, 0}
}
func (f *FrameTitle) SetTitle(title string) {
@ -42,9 +47,9 @@ func (f FrameTitle) HeaderHeight() int {
}
func (f FrameTitle) headerView() string {
style := inactiveHeaderStyle
style := f.style.InactiveTitle
if f.active {
style = f.activeStyle
style = f.style.ActiveTitle
}
titleText := f.header

View file

@ -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
}

View file

@ -0,0 +1,85 @@
package layout
import (
"bufio"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"strings"
)
type Compositor struct {
background ResizingModel
foreground ResizingModel
foreX, foreY int
foreW, foreH int
}
func NewCompositor(background ResizingModel) *Compositor {
return &Compositor{
background: background,
}
}
func (c *Compositor) Init() tea.Cmd {
return c.background.Init()
}
func (c *Compositor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// TODO: allow the compositor the
newM, cmd := c.background.Update(msg)
c.background = newM.(ResizingModel)
return c, cmd
}
func (c *Compositor) SetOverlay(m ResizingModel, x, y, w, h int) {
c.foreground = m
c.foreX, c.foreY = x, y
c.foreW, c.foreH = w, h
}
func (c *Compositor) View() string {
if c.foreground == nil {
return c.background.View()
}
// Need to compose
backgroundView := c.background.View()
foregroundViewLines := strings.Split(c.foreground.View(), "\n")
backgroundScanner := bufio.NewScanner(strings.NewReader(backgroundView))
compositeOutput := new(strings.Builder)
r := 0
for backgroundScanner.Scan() {
if r > 0 {
compositeOutput.WriteRune('\n')
}
line := backgroundScanner.Text()
if r >= c.foreY && r < c.foreY+c.foreH {
compositeOutput.WriteString(line[:c.foreX])
foregroundScanPos := r - c.foreY
if foregroundScanPos < len(foregroundViewLines) {
displayLine := foregroundViewLines[foregroundScanPos]
compositeOutput.WriteString(lipgloss.PlaceHorizontal(c.foreW, lipgloss.Left, displayLine, lipgloss.WithWhitespaceChars(" ")))
}
compositeOutput.WriteString(line[c.foreX+c.foreW:])
} else {
compositeOutput.WriteString(line)
}
r++
}
return compositeOutput.String()
}
func (c *Compositor) Resize(w, h int) ResizingModel {
c.background = c.background.Resize(w, h)
if c.foreground != nil {
c.foreground = c.foreground.Resize(c.foreW, c.foreH)
}
return c
}

View file

@ -12,15 +12,21 @@ import (
// event is received, focus will be torn away and the user will be given a prompt the enter text.
type StatusAndPrompt struct {
model layout.ResizingModel
style Style
modeLine string
statusMessage string
pendingInput *events.PromptForInputMsg
textInput textinput.Model
width int
}
func New(model layout.ResizingModel, initialMsg string) *StatusAndPrompt {
type Style struct {
ModeLine lipgloss.Style
}
func New(model layout.ResizingModel, initialMsg string, style Style) *StatusAndPrompt {
textInput := textinput.New()
return &StatusAndPrompt{model: model, statusMessage: initialMsg, textInput: textInput}
return &StatusAndPrompt{model: model, style: style, statusMessage: initialMsg, modeLine: "", textInput: textInput}
}
func (s *StatusAndPrompt) Init() tea.Cmd {
@ -33,7 +39,12 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.statusMessage = "Error: " + msg.Error()
case events.StatusMsg:
s.statusMessage = string(msg)
case events.ModeMessage:
s.modeLine = string(msg)
case events.MessageWithStatus:
if hasModeMessage, ok := msg.(events.MessageWithMode); ok {
s.modeLine = hasModeMessage.ModeMessage()
}
s.statusMessage = msg.StatusMessage()
case events.PromptForInputMsg:
if s.pendingInput != nil {
@ -61,6 +72,8 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.textInput = newTextInput
return s, cmd
}
} else {
s.statusMessage = ""
}
}
@ -85,8 +98,14 @@ func (s *StatusAndPrompt) Resize(w, h int) layout.ResizingModel {
}
func (s *StatusAndPrompt) viewStatus() string {
modeLine := s.style.ModeLine.Render(lipgloss.PlaceHorizontal(s.width, lipgloss.Left, s.modeLine, lipgloss.WithWhitespaceChars(" ")))
var statusLine string
if s.pendingInput != nil {
return s.textInput.View()
statusLine = s.textInput.View()
} else {
statusLine = s.statusMessage
}
return s.statusMessage
return lipgloss.JoinVertical(lipgloss.Top, modeLine, statusLine)
}

View file

@ -0,0 +1,29 @@
package styles
import (
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt"
)
type Styles struct {
Frames frame.Style
StatusAndPrompt statusandprompt.Style
}
var DefaultStyles = Styles{
Frames: frame.Style{
ActiveTitle: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff")),
InactiveTitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1")),
},
StatusAndPrompt: statusandprompt.Style{
ModeLine: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1")),
},
}

View file

@ -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}

View file

@ -7,6 +7,7 @@ import (
"github.com/lmika/awstools/internal/dynamo-browse/controllers"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils"
)
@ -26,8 +27,8 @@ type Model struct {
w, h int
}
func New(submodel tea.Model) *Model {
frameTitle := frame.NewFrameTitle("Select table", false, activeHeaderStyle)
func New(submodel tea.Model, uiStyles styles.Styles) *Model {
frameTitle := frame.NewFrameTitle("Select table", false, uiStyles.Frames)
return &Model{frameTitle: frameTitle, submodel: submodel}
}

View file

@ -0,0 +1,29 @@
package styles
import (
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt"
)
type Styles struct {
Frames frame.Style
StatusAndPrompt statusandprompt.Style
}
var DefaultStyles = Styles{
Frames: frame.Style{
ActiveTitle: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1")),
InactiveTitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1")),
},
StatusAndPrompt: statusandprompt.Style{
ModeLine: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1")),
},
}

View file

@ -2,22 +2,23 @@ package fullviewlinedetails
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/slog-view/models"
"github.com/lmika/awstools/internal/slog-view/ui/linedetails"
)
type Model struct {
submodel tea.Model
submodel tea.Model
lineDetails *linedetails.Model
visible bool
}
func NewModel(submodel tea.Model) *Model {
func NewModel(submodel tea.Model, style frame.Style) *Model {
return &Model{
submodel: submodel,
lineDetails: linedetails.New(),
submodel: submodel,
lineDetails: linedetails.New(style),
}
}
@ -49,6 +50,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Model) ViewItem(item *models.LogLine) {
m.visible = true
m.lineDetails.SetSelectedItem(item)
m.lineDetails.SetFocused(true)
}
func (m *Model) View() string {

View file

@ -10,13 +10,6 @@ import (
"github.com/lmika/awstools/internal/slog-view/models"
)
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#9c9c9c"))
)
type Model struct {
frameTitle frame.FrameTitle
viewport viewport.Model
@ -27,11 +20,11 @@ type Model struct {
selectedItem *models.LogLine
}
func New() *Model {
func New(style frame.Style) *Model {
viewport := viewport.New(0, 0)
viewport.SetContent("")
return &Model{
frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle),
frameTitle: frame.NewFrameTitle("Item", false, style),
viewport: viewport,
}
}

View file

@ -1,22 +1,15 @@
package loglines
import (
table "github.com/calyptia/go-bubble-table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/slog-view/models"
table "github.com/lmika/go-bubble-table"
"path/filepath"
)
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#9c9c9c"))
)
type Model struct {
frameTitle frame.FrameTitle
table table.Model
@ -26,9 +19,9 @@ type Model struct {
w, h int
}
func New() *Model {
frameTitle := frame.NewFrameTitle("File: ", true, activeHeaderStyle)
table := table.New([]string{"level", "error", "message"}, 0, 0)
func New(style frame.Style) *Model {
frameTitle := frame.NewFrameTitle("File: ", true, style)
table := table.New(table.SimpleColumns{"level", "error", "message"}, 0, 0)
return &Model{
frameTitle: frameTitle,
@ -40,7 +33,7 @@ func (m *Model) SetLogFile(newLogFile *models.LogFile) {
m.logFile = newLogFile
m.frameTitle.SetTitle("File: " + filepath.Base(newLogFile.Filename))
cols := []string{"level", "error", "message"}
cols := table.SimpleColumns{"level", "error", "message"}
newTbl := table.New(cols, m.w, m.h-m.frameTitle.HeaderHeight())
newRows := make([]table.Row, len(newLogFile.Lines))

View file

@ -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"

View file

@ -6,38 +6,40 @@ import (
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt"
"github.com/lmika/awstools/internal/slog-view/controllers"
"github.com/lmika/awstools/internal/slog-view/styles"
"github.com/lmika/awstools/internal/slog-view/ui/fullviewlinedetails"
"github.com/lmika/awstools/internal/slog-view/ui/linedetails"
"github.com/lmika/awstools/internal/slog-view/ui/loglines"
)
type Model struct {
controller *controllers.LogFileController
cmdController *commandctrl.CommandController
controller *controllers.LogFileController
cmdController *commandctrl.CommandController
root tea.Model
logLines *loglines.Model
lineDetails *linedetails.Model
statusAndPrompt *statusandprompt.StatusAndPrompt
root tea.Model
logLines *loglines.Model
lineDetails *linedetails.Model
statusAndPrompt *statusandprompt.StatusAndPrompt
fullViewLineDetails *fullviewlinedetails.Model
}
func NewModel(controller *controllers.LogFileController, cmdController *commandctrl.CommandController) Model {
logLines := loglines.New()
lineDetails := linedetails.New()
defaultStyles := styles.DefaultStyles
logLines := loglines.New(defaultStyles.Frames)
lineDetails := linedetails.New(defaultStyles.Frames)
box := layout.NewVBox(layout.LastChildFixedAt(17), logLines, lineDetails)
fullViewLineDetails := fullviewlinedetails.NewModel(box)
statusAndPrompt := statusandprompt.New(fullViewLineDetails, "")
fullViewLineDetails := fullviewlinedetails.NewModel(box, defaultStyles.Frames)
statusAndPrompt := statusandprompt.New(fullViewLineDetails, "", defaultStyles.StatusAndPrompt)
root := layout.FullScreen(statusAndPrompt)
return Model{
controller: controller,
cmdController: cmdController,
root: root,
statusAndPrompt: statusAndPrompt,
logLines: logLines,
lineDetails: lineDetails,
controller: controller,
cmdController: cmdController,
root: root,
statusAndPrompt: statusAndPrompt,
logLines: logLines,
lineDetails: lineDetails,
fullViewLineDetails: fullViewLineDetails,
}
}

View file

@ -0,0 +1,29 @@
package styles
import (
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt"
)
type Styles struct {
Frames frame.Style
StatusAndPrompt statusandprompt.Style
}
var DefaultStyles = Styles{
Frames: frame.Style{
ActiveTitle: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff")),
InactiveTitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1")),
},
StatusAndPrompt: statusandprompt.Style{
ModeLine: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1")),
},
}

View file

@ -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)

View file

@ -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"
)

View file

@ -4,6 +4,7 @@ import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/ssm-browse/models"
"github.com/lmika/awstools/internal/ssm-browse/services/ssmparameters"
"sync"
)
@ -12,15 +13,15 @@ type SSMController struct {
service *ssmparameters.Service
// state
mutex *sync.Mutex
mutex *sync.Mutex
prefix string
}
func New(service *ssmparameters.Service) *SSMController {
return &SSMController{
service: service,
prefix: "/",
mutex: new(sync.Mutex),
prefix: "/",
mutex: new(sync.Mutex),
}
}
@ -32,7 +33,7 @@ func (c *SSMController) Fetch() tea.Cmd {
}
return NewParameterListMsg{
Prefix: c.prefix,
Prefix: c.prefix,
Parameters: res,
}
}
@ -50,8 +51,50 @@ func (c *SSMController) ChangePrefix(newPrefix string) tea.Cmd {
c.prefix = newPrefix
return NewParameterListMsg{
Prefix: c.prefix,
Prefix: c.prefix,
Parameters: res,
}
}
}
}
func (c *SSMController) Clone(param models.SSMParameter) tea.Cmd {
return events.PromptForInput("New key: ", func(value string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if err := c.service.Clone(ctx, param, value); err != nil {
return events.Error(err)
}
res, err := c.service.List(context.Background(), c.prefix)
if err != nil {
return events.Error(err)
}
return NewParameterListMsg{
Prefix: c.prefix,
Parameters: res,
}
}
})
}
func (c *SSMController) DeleteParameter(param models.SSMParameter) tea.Cmd {
return events.Confirm("delete parameter? ", func() tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if err := c.service.Delete(ctx, param); err != nil {
return events.Error(err)
}
res, err := c.service.List(context.Background(), c.prefix)
if err != nil {
return events.Error(err)
}
return NewParameterListMsg{
Prefix: c.prefix,
Parameters: res,
}
}
})
}

View file

@ -1,10 +1,13 @@
package models
import "github.com/aws/aws-sdk-go-v2/service/ssm/types"
type SSMParameters struct {
Items []SSMParameter
}
type SSMParameter struct {
Name string
Type types.ParameterType
Value string
}

View file

@ -4,11 +4,14 @@ import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ssm"
"github.com/aws/aws-sdk-go-v2/service/ssm/types"
"github.com/lmika/awstools/internal/ssm-browse/models"
"github.com/pkg/errors"
"log"
)
const defaultKMSKeyIDForSecureStrings = "alias/aws/ssm"
type Provider struct {
client *ssm.Client
}
@ -23,13 +26,14 @@ func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*mode
log.Printf("new prefix: %v", prefix)
pager := ssm.NewGetParametersByPathPaginator(p.client, &ssm.GetParametersByPathInput{
Path: aws.String(prefix),
Recursive: true,
Path: aws.String(prefix),
Recursive: true,
WithDecryption: true,
})
items := make([]models.SSMParameter, 0)
outer: for pager.HasMorePages() {
outer:
for pager.HasMorePages() {
out, err := pager.NextPage(ctx)
if err != nil {
return nil, errors.Wrap(err, "cannot get parameters from path")
@ -38,6 +42,7 @@ func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*mode
for _, p := range out.Parameters {
items = append(items, models.SSMParameter{
Name: aws.ToString(p.Name),
Type: p.Type,
Value: aws.ToString(p.Value),
})
if len(items) >= maxCount {
@ -48,3 +53,32 @@ func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*mode
return &models.SSMParameters{Items: items}, nil
}
func (p *Provider) Put(ctx context.Context, param models.SSMParameter, override bool) error {
in := &ssm.PutParameterInput{
Name: aws.String(param.Name),
Type: param.Type,
Value: aws.String(param.Value),
Overwrite: override,
}
if param.Type == types.ParameterTypeSecureString {
in.KeyId = aws.String(defaultKMSKeyIDForSecureStrings)
}
_, err := p.client.PutParameter(ctx, in)
if err != nil {
return errors.Wrap(err, "unable to put new SSM parameter")
}
return nil
}
func (p *Provider) Delete(ctx context.Context, param models.SSMParameter) error {
_, err := p.client.DeleteParameter(ctx, &ssm.DeleteParameterInput{
Name: aws.String(param.Name),
})
if err != nil {
return errors.Wrap(err, "unable to delete SSM parameter")
}
return nil
}

View file

@ -7,4 +7,6 @@ import (
type SSMProvider interface {
List(ctx context.Context, prefix string, maxCount int) (*models.SSMParameters, error)
Put(ctx context.Context, param models.SSMParameter, override bool) error
Delete(ctx context.Context, param models.SSMParameter) error
}

View file

@ -17,4 +17,17 @@ func NewService(provider SSMProvider) *Service {
func (s *Service) List(ctx context.Context, prefix string) (*models.SSMParameters, error) {
return s.provider.List(ctx, prefix, 100)
}
}
func (s *Service) Clone(ctx context.Context, param models.SSMParameter, newName string) error {
newParam := models.SSMParameter{
Name: newName,
Type: param.Type,
Value: param.Value,
}
return s.provider.Put(ctx, newParam, false)
}
func (s *Service) Delete(ctx context.Context, param models.SSMParameter) error {
return s.provider.Delete(ctx, param)
}

View file

@ -0,0 +1,29 @@
package styles
import (
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt"
)
type Styles struct {
Frames frame.Style
StatusAndPrompt statusandprompt.Style
}
var DefaultStyles = Styles{
Frames: frame.Style{
ActiveTitle: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#c144ff")),
InactiveTitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1")),
},
StatusAndPrompt: statusandprompt.Style{
ModeLine: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1")),
},
}

View file

@ -3,11 +3,14 @@ package ui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/common/ui/commandctrl"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt"
"github.com/lmika/awstools/internal/ssm-browse/controllers"
"github.com/lmika/awstools/internal/ssm-browse/styles"
"github.com/lmika/awstools/internal/ssm-browse/ui/ssmdetails"
"github.com/lmika/awstools/internal/ssm-browse/ui/ssmlist"
"github.com/pkg/errors"
)
type Model struct {
@ -21,11 +24,28 @@ type Model struct {
}
func NewModel(controller *controllers.SSMController, cmdController *commandctrl.CommandController) Model {
ssmList := ssmlist.New()
ssmdDetails := ssmdetails.New()
defaultStyles := styles.DefaultStyles
ssmList := ssmlist.New(defaultStyles.Frames)
ssmdDetails := ssmdetails.New(defaultStyles.Frames)
statusAndPrompt := statusandprompt.New(
layout.NewVBox(layout.LastChildFixedAt(17), ssmList, ssmdDetails),
"")
layout.NewVBox(layout.LastChildFixedAt(17), ssmList, ssmdDetails), "", defaultStyles.StatusAndPrompt)
cmdController.AddCommands(&commandctrl.CommandContext{
Commands: map[string]commandctrl.Command{
"clone": func(args []string) tea.Cmd {
if currentParam := ssmList.CurrentParameter(); currentParam != nil {
return controller.Clone(*currentParam)
}
return events.SetError(errors.New("no parameter selected"))
},
"delete": func(args []string) tea.Cmd {
if currentParam := ssmList.CurrentParameter(); currentParam != nil {
return controller.DeleteParameter(*currentParam)
}
return events.SetError(errors.New("no parameter selected"))
},
},
})
root := layout.FullScreen(statusAndPrompt)

View file

@ -11,13 +11,6 @@ import (
"strings"
)
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#c144ff"))
)
type Model struct {
frameTitle frame.FrameTitle
viewport viewport.Model
@ -28,11 +21,11 @@ type Model struct {
selectedItem *models.SSMParameter
}
func New() *Model {
func New(style frame.Style) *Model {
viewport := viewport.New(0, 0)
viewport.SetContent("")
return &Model{
frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle),
frameTitle: frame.NewFrameTitle("Item", false, style),
viewport: viewport,
}
}

View file

@ -1,19 +1,12 @@
package ssmlist
import (
table "github.com/calyptia/go-bubble-table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/ssm-browse/models"
)
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#c144ff"))
table "github.com/lmika/go-bubble-table"
)
type Model struct {
@ -25,9 +18,9 @@ type Model struct {
w, h int
}
func New() *Model {
frameTitle := frame.NewFrameTitle("SSM: /", true, activeHeaderStyle)
table := table.New([]string{"name", "type", "value"}, 0, 0)
func New(style frame.Style) *Model {
frameTitle := frame.NewFrameTitle("SSM: /", true, style)
table := table.New(table.SimpleColumns{"name", "type", "value"}, 0, 0)
return &Model{
frameTitle: frameTitle,
@ -41,7 +34,7 @@ func (m *Model) SetPrefix(newPrefix string) {
func (m *Model) SetParameters(parameters *models.SSMParameters) {
m.parameters = parameters
cols := []string{"name", "type", "value"}
cols := table.SimpleColumns{"name", "type", "value"}
newTbl := table.New(cols, m.w, m.h-m.frameTitle.HeaderHeight())
newRows := make([]table.Row, len(parameters.Items))
@ -85,6 +78,14 @@ func (m *Model) emitNewSelectedParameter() tea.Cmd {
}
}
func (m *Model) CurrentParameter() *models.SSMParameter {
if row, ok := m.table.SelectedRow().(itemTableRow); ok {
return &(row.item)
}
return nil
}
func (m *Model) View() string {
return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View())
}

View file

@ -2,7 +2,7 @@ package ssmlist
import (
"fmt"
table "github.com/calyptia/go-bubble-table"
table "github.com/lmika/go-bubble-table"
"github.com/lmika/awstools/internal/ssm-browse/models"
"io"
"strings"
@ -14,7 +14,7 @@ type itemTableRow struct {
func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) {
firstLine := strings.SplitN(mtr.item.Value, "\n", 2)[0]
line := fmt.Sprintf("%s\t%s\t%s", mtr.item.Name, "String", firstLine)
line := fmt.Sprintf("%s\t%s\t%s", mtr.item.Name, mtr.item.Type, firstLine)
if index == model.Cursor() {
fmt.Fprintln(w, model.Styles.SelectedRow.Render(line))