From 7526c095eee717942393656fa7738c66d5fd998d Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 23 Mar 2022 15:40:31 +1100 Subject: [PATCH] sqs-browse: a lot of work to try to keep UI complexity down Added the notion of controllers and a dispatcher which will queue up operations --- cmd/dynamo-browse/main.go | 9 +- cmd/sqs-browse/main.go | 27 ++- go.mod | 1 + go.sum | 1 + internal/common/ui/dispatcher/context.go | 30 +++ internal/common/ui/dispatcher/dispatcher.go | 49 +++++ internal/common/ui/dispatcher/iface.go | 7 + internal/common/ui/events/errors.go | 16 ++ internal/common/ui/uimodels/context.go | 15 ++ internal/common/ui/uimodels/iface.go | 10 + internal/common/ui/uimodels/operations.go | 14 ++ internal/common/ui/uimodels/promptvalue.go | 15 ++ .../{ui => controllers}/events.go | 7 +- .../dynamo-browse/controllers/tableread.go | 44 +++++ .../dynamo-browse/controllers/tablewrite.go | 53 ++++++ .../providers/dynamo/provider.go | 9 + .../dynamo-browse/services/tables/iface.go | 2 + .../dynamo-browse/services/tables/service.go | 9 + internal/dynamo-browse/ui/model.go | 180 ++++++++++++------ internal/sqs-browse/controllers/forward.go | 39 ++++ internal/sqs-browse/providers/sqs/provider.go | 14 ++ .../sqs-browse/services/messages/iface.go | 2 +- .../sqs-browse/services/messages/service.go | 14 +- internal/sqs-browse/ui/model.go | 132 +++++++++++-- 24 files changed, 602 insertions(+), 97 deletions(-) create mode 100644 internal/common/ui/dispatcher/context.go create mode 100644 internal/common/ui/dispatcher/dispatcher.go create mode 100644 internal/common/ui/dispatcher/iface.go create mode 100644 internal/common/ui/events/errors.go create mode 100644 internal/common/ui/uimodels/context.go create mode 100644 internal/common/ui/uimodels/iface.go create mode 100644 internal/common/ui/uimodels/operations.go create mode 100644 internal/common/ui/uimodels/promptvalue.go rename internal/dynamo-browse/{ui => controllers}/events.go (52%) create mode 100644 internal/dynamo-browse/controllers/tableread.go create mode 100644 internal/dynamo-browse/controllers/tablewrite.go create mode 100644 internal/sqs-browse/controllers/forward.go diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 1c83968..4a06960 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -7,6 +7,8 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb" tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" "github.com/lmika/awstools/internal/dynamo-browse/ui" @@ -38,7 +40,12 @@ func main() { tableService := tables.NewService(dynamoProvider) loopback := &msgLoopback{} - uiModel := ui.NewModel(tableService, loopback, *flagTable) + uiDispatcher := dispatcher.NewDispatcher(loopback) + + tableReadController := controllers.NewTableReadController(tableService, *flagTable) + tableWriteController := controllers.NewTableWriteController(tableService, tableReadController, *flagTable) + + uiModel := ui.NewModel(uiDispatcher, tableReadController, tableWriteController) p := tea.NewProgram(uiModel, tea.WithAltScreen()) loopback.program = p diff --git a/cmd/sqs-browse/main.go b/cmd/sqs-browse/main.go index 97c0477..18daa58 100644 --- a/cmd/sqs-browse/main.go +++ b/cmd/sqs-browse/main.go @@ -7,9 +7,12 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sqs" tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/sqs-browse/controllers" "github.com/lmika/awstools/internal/sqs-browse/models" "github.com/lmika/awstools/internal/sqs-browse/providers/memstore" sqsprovider "github.com/lmika/awstools/internal/sqs-browse/providers/sqs" + "github.com/lmika/awstools/internal/sqs-browse/services/messages" "github.com/lmika/awstools/internal/sqs-browse/services/pollmessage" "github.com/lmika/awstools/internal/sqs-browse/ui" "github.com/lmika/events" @@ -20,6 +23,7 @@ import ( func main() { var flagQueue = flag.String("q", "", "queue to poll") + var flagTarget = flag.String("t", "", "target queue to push to") flag.Parse() ctx := context.Background() @@ -32,12 +36,19 @@ func main() { bus := events.New() msgStore := memstore.NewStore() - msgPoller := sqsprovider.NewProvider(sqsClient) + sqsProvider := sqsprovider.NewProvider(sqsClient) - pollService := pollmessage.NewService(msgStore, msgPoller, *flagQueue, bus) + messageService := messages.NewService(sqsProvider) + pollService := pollmessage.NewService(msgStore, sqsProvider, *flagQueue, bus) - uiModel := ui.NewModel() + msgSendingHandlers := controllers.NewMessageSendingController(messageService, *flagTarget) + + loopback := &msgLoopback{} + uiDispatcher := dispatcher.NewDispatcher(loopback) + + uiModel := ui.NewModel(uiDispatcher, msgSendingHandlers) p := tea.NewProgram(uiModel, tea.WithAltScreen()) + loopback.program = p bus.On("new-messages", func(m []*models.Message) { p.Send(ui.NewMessagesEvent(m)) }) @@ -58,4 +69,12 @@ func main() { fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) } -} \ No newline at end of file +} + +type msgLoopback struct { + program *tea.Program +} + +func (m *msgLoopback) Send(msg tea.Msg) { + m.program.Send(msg) +} diff --git a/go.mod b/go.mod index a734757..9db652d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/lmika/awstools go 1.17 require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2 v1.15.0 // indirect github.com/aws/aws-sdk-go-v2/config v1.13.1 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.8.0 // indirect diff --git a/go.sum b/go.sum index 3884602..55f630b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.13.0 h1:1XIXAfxsEmbhbj5ry3D3vX+6ZcUYvIqSm4CWWEuGZCA= github.com/aws/aws-sdk-go-v2 v1.13.0/go.mod h1:L6+ZpqHaLbAaxsqV0L4cvxZY7QupWJB4fhkf8LXvC7w= diff --git a/internal/common/ui/dispatcher/context.go b/internal/common/ui/dispatcher/context.go new file mode 100644 index 0000000..181128e --- /dev/null +++ b/internal/common/ui/dispatcher/context.go @@ -0,0 +1,30 @@ +package dispatcher + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" +) + +type dispatcherContext struct { + d *Dispatcher +} + +func (dc dispatcherContext) Messagef(format string, args ...interface{}) { + dc.d.publisher.Send(events.Message(fmt.Sprintf(format, args...))) +} + +func (dc dispatcherContext) Send(teaMessage tea.Msg) { + dc.d.publisher.Send(teaMessage) +} + +func (dc dispatcherContext) Message(msg string) { + dc.d.publisher.Send(events.Message(msg)) +} + +func (dc dispatcherContext) Input(prompt string, onDone uimodels.Operation) { + dc.d.publisher.Send(events.PromptForInput{ + OnDone: onDone, + }) +} diff --git a/internal/common/ui/dispatcher/dispatcher.go b/internal/common/ui/dispatcher/dispatcher.go new file mode 100644 index 0000000..e126b4d --- /dev/null +++ b/internal/common/ui/dispatcher/dispatcher.go @@ -0,0 +1,49 @@ +package dispatcher + +import ( + "context" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/pkg/errors" + "sync" +) + +type Dispatcher struct { + mutex *sync.Mutex + runningOp uimodels.Operation + publisher MessagePublisher +} + + +func NewDispatcher(publisher MessagePublisher) *Dispatcher { + return &Dispatcher{ + mutex: new(sync.Mutex), + publisher: publisher, + } +} + +func (d *Dispatcher) Start(ctx context.Context, operation uimodels.Operation) { + d.mutex.Lock() + defer d.mutex.Unlock() + + if d.runningOp != nil { + d.publisher.Send(events.Error(errors.New("operation already running"))) + } + + d.runningOp = operation + go func() { + subCtx := uimodels.WithContext(ctx, dispatcherContext{d}) + + err := operation.Execute(subCtx) + if err != nil { + d.publisher.Send(events.Error(err)) + } + + d.mutex.Lock() + defer d.mutex.Unlock() + d.runningOp = nil + }() +} + + + diff --git a/internal/common/ui/dispatcher/iface.go b/internal/common/ui/dispatcher/iface.go new file mode 100644 index 0000000..064624d --- /dev/null +++ b/internal/common/ui/dispatcher/iface.go @@ -0,0 +1,7 @@ +package dispatcher + +import tea "github.com/charmbracelet/bubbletea" + +type MessagePublisher interface { + Send(msg tea.Msg) +} \ No newline at end of file diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go new file mode 100644 index 0000000..2f96a3f --- /dev/null +++ b/internal/common/ui/events/errors.go @@ -0,0 +1,16 @@ +package events + +import ( + "github.com/lmika/awstools/internal/common/ui/uimodels" +) + +// Error indicates that an error occurred +type Error error + +// Message indicates that a message should be shown to the user +type Message string + +// PromptForInput indicates that the context is requesting a line of input +type PromptForInput struct { + OnDone uimodels.Operation +} \ No newline at end of file diff --git a/internal/common/ui/uimodels/context.go b/internal/common/ui/uimodels/context.go new file mode 100644 index 0000000..dae4ab4 --- /dev/null +++ b/internal/common/ui/uimodels/context.go @@ -0,0 +1,15 @@ +package uimodels + +import "context" + +type uiContextKeyType struct {} +var uiContextKey = uiContextKeyType{} + +func Ctx(ctx context.Context) UIContext { + uiCtx, _ := ctx.Value(uiContextKey).(UIContext) + return uiCtx +} + +func WithContext(ctx context.Context, uiContext UIContext) context.Context { + return context.WithValue(ctx, uiContextKey, uiContext) +} diff --git a/internal/common/ui/uimodels/iface.go b/internal/common/ui/uimodels/iface.go new file mode 100644 index 0000000..002e83f --- /dev/null +++ b/internal/common/ui/uimodels/iface.go @@ -0,0 +1,10 @@ +package uimodels + +import tea "github.com/charmbracelet/bubbletea" + +type UIContext interface { + Send(teaMessage tea.Msg) + Message(msg string) + Messagef(format string, args ...interface{}) + Input(prompt string, onDone Operation) +} diff --git a/internal/common/ui/uimodels/operations.go b/internal/common/ui/uimodels/operations.go new file mode 100644 index 0000000..c1f504a --- /dev/null +++ b/internal/common/ui/uimodels/operations.go @@ -0,0 +1,14 @@ +package uimodels + +import "context" + +type Operation interface { + Execute(ctx context.Context) error +} + +type OperationFn func(ctx context.Context) error + +func (f OperationFn) Execute(ctx context.Context) error { + return f(ctx) +} + diff --git a/internal/common/ui/uimodels/promptvalue.go b/internal/common/ui/uimodels/promptvalue.go new file mode 100644 index 0000000..d829687 --- /dev/null +++ b/internal/common/ui/uimodels/promptvalue.go @@ -0,0 +1,15 @@ +package uimodels + +import "context" + +type promptValueKeyType struct {} +var promptValueKey = promptValueKeyType{} + +func PromptValue(ctx context.Context) string { + value, _ := ctx.Value(promptValueKey).(string) + return value +} + +func WithPromptValue(ctx context.Context, value string) context.Context { + return context.WithValue(ctx, promptValueKey, value) +} diff --git a/internal/dynamo-browse/ui/events.go b/internal/dynamo-browse/controllers/events.go similarity index 52% rename from internal/dynamo-browse/ui/events.go rename to internal/dynamo-browse/controllers/events.go index 9165527..6f51b9a 100644 --- a/internal/dynamo-browse/ui/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -1,10 +1,7 @@ -package ui +package controllers import "github.com/lmika/awstools/internal/dynamo-browse/models" -type newResultSet struct { +type NewResultSet struct { ResultSet *models.ResultSet } - -type setStatusMessage string -type errorRaised error \ No newline at end of file diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go new file mode 100644 index 0000000..71a6dae --- /dev/null +++ b/internal/dynamo-browse/controllers/tableread.go @@ -0,0 +1,44 @@ +package controllers + +import ( + "context" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" +) + +type TableReadController struct { + tableService *tables.Service + tableName string +} + +func NewTableReadController(tableService *tables.Service, tableName string) *TableReadController { + return &TableReadController{ + tableService: tableService, + tableName: tableName, + } +} + +func (c *TableReadController) Scan() uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + return c.doScan(ctx, false) + }) +} + +func (c *TableReadController) doScan(ctx context.Context, quiet bool) error { + uiCtx := uimodels.Ctx(ctx) + + if !quiet { + uiCtx.Message("Scanning...") + } + + resultSet, err := c.tableService.Scan(ctx, c.tableName) + if err != nil { + return err + } + + if !quiet { + uiCtx.Messagef("Found %d items", len(resultSet.Items)) + } + uiCtx.Send(NewResultSet{resultSet}) + return nil +} \ No newline at end of file diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go new file mode 100644 index 0000000..1482448 --- /dev/null +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -0,0 +1,53 @@ +package controllers + +import ( + "context" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/pkg/errors" +) + +type TableWriteController struct { + tableService *tables.Service + tableReadControllers *TableReadController + tableName string +} + +func NewTableWriteController(tableService *tables.Service, tableReadControllers *TableReadController, tableName string) *TableWriteController { + return &TableWriteController{ + tableService: tableService, + tableReadControllers: tableReadControllers, + tableName: tableName, + } +} + +func (c *TableWriteController) Delete(item models.Item) uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + + // TODO: only do if rw mode enabled + + uiCtx.Input("Delete item?", uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + + if uimodels.PromptValue(ctx) != "y" { + return errors.New("operation aborted") + } + + // Delete the item + if err := c.tableService.Delete(ctx, c.tableName, item); err != nil { + return err + } + + // Rescan to get updated items + if err := c.tableReadControllers.doScan(ctx, true); err != nil { + return err + } + + uiCtx.Message("Item deleted") + return nil + })) + return nil + }) +} diff --git a/internal/dynamo-browse/providers/dynamo/provider.go b/internal/dynamo-browse/providers/dynamo/provider.go index cb07719..f1910c1 100644 --- a/internal/dynamo-browse/providers/dynamo/provider.go +++ b/internal/dynamo-browse/providers/dynamo/provider.go @@ -4,6 +4,7 @@ import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "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" "github.com/pkg/errors" ) @@ -31,3 +32,11 @@ func (p *Provider) ScanItems(ctx context.Context, tableName string) ([]models.It return items, nil } + +func (p *Provider) DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error { + _, err := p.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{ + TableName: aws.String(tableName), + Key: key, + }) + return errors.Wrap(err, "could not delete item") +} \ No newline at end of file diff --git a/internal/dynamo-browse/services/tables/iface.go b/internal/dynamo-browse/services/tables/iface.go index b8a059f..1e46d27 100644 --- a/internal/dynamo-browse/services/tables/iface.go +++ b/internal/dynamo-browse/services/tables/iface.go @@ -2,9 +2,11 @@ package tables import ( "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/awstools/internal/dynamo-browse/models" ) type TableProvider interface { ScanItems(ctx context.Context, tableName string) ([]models.Item, error) + DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error } diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index b3fbbd5..fde7cb0 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -2,6 +2,7 @@ package tables import ( "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/pkg/errors" "sort" @@ -50,3 +51,11 @@ func (s *Service) Scan(ctx context.Context, table string) (*models.ResultSet, er Items: results, }, nil } + +func (s *Service) Delete(ctx context.Context, name string, item models.Item) error { + // TODO: do not hardcode keys + return s.provider.DeleteItem(ctx, name, map[string]types.AttributeValue{ + "pk": item["pk"], + "sk": item["sk"], + }) +} diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 30b80f1..9e1549b 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -5,42 +5,59 @@ import ( "fmt" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" table "github.com/calyptia/go-bubble-table" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/models" - "github.com/lmika/awstools/internal/dynamo-browse/services/tables" - "log" "strings" "text/tabwriter" ) +var ( + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#4479ff")) +) + type uiModel struct { table table.Model viewport viewport.Model - msgPublisher MessagePublisher - tableService *tables.Service - tableName string - tableWidth, tableHeight int ready bool resultSet *models.ResultSet message string + + pendingInput *events.PromptForInput + textInput textinput.Model + + dispatcher *dispatcher.Dispatcher + tableReadController *controllers.TableReadController + tableWriteController *controllers.TableWriteController } -func NewModel(tableService *tables.Service, msgPublisher MessagePublisher, tableName string) tea.Model { +func NewModel(dispatcher *dispatcher.Dispatcher, tableReadController *controllers.TableReadController, tableWriteController *controllers.TableWriteController) tea.Model { tbl := table.New([]string{"pk", "sk"}, 100, 20) rows := make([]table.Row, 0) tbl.SetRows(rows) + textInput := textinput.New() + model := uiModel{ - table: tbl, - tableService: tableService, - tableName: tableName, - msgPublisher: msgPublisher, - message: "Press s to scan", + table: tbl, + message: "Press s to scan", + textInput: textInput, + + dispatcher: dispatcher, + tableReadController: tableReadController, + tableWriteController: tableWriteController, } return model @@ -65,16 +82,19 @@ func (m *uiModel) updateTable() { m.table = newTbl } +func (m *uiModel) selectedItem() (itemTableRow, bool) { + if m.ready && m.resultSet != nil && len(m.resultSet.Items) > 0 { + selectedItem, ok := m.table.SelectedRow().(itemTableRow) + if ok { + return selectedItem, true + } + } + + return itemTableRow{}, false +} + func (m *uiModel) updateViewportToSelectedMessage() { - if !m.ready { - return - } - - if m.resultSet == nil || len(m.resultSet.Items) == 0 { - return - } - - selectedItem, ok := m.table.SelectedRow().(itemTableRow) + selectedItem, ok := m.selectedItem() if !ok { m.viewport.SetContent("(no row selected)") return @@ -83,17 +103,15 @@ func (m *uiModel) updateViewportToSelectedMessage() { viewportContent := &strings.Builder{} tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0) for _, colName := range selectedItem.resultSet.Columns { - fmt.Fprintf(tabWriter, "%v\t", colName) - switch colVal := selectedItem.item[colName].(type) { case nil: - fmt.Fprintln(tabWriter, "(nil)") + break case *types.AttributeValueMemberS: - fmt.Fprintln(tabWriter, colVal.Value) + fmt.Fprintf(tabWriter, "%v\tS\t%s\n", colName, colVal.Value) case *types.AttributeValueMemberN: - fmt.Fprintln(tabWriter, colVal.Value) + fmt.Fprintf(tabWriter, "%v\tN\t%s\n", colName, colVal.Value) default: - fmt.Fprintln(tabWriter, "(other)") + fmt.Fprintf(tabWriter, "%v\t?\t%s\n", colName, "(other)") } } @@ -102,26 +120,38 @@ func (m *uiModel) updateViewportToSelectedMessage() { } func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var textInputCommands tea.Cmd + switch msg := msg.(type) { - case setStatusMessage: - m.message = "" - case errorRaised: - m.message = "Error: " + msg.Error() - case newResultSet: + + // Local events + case controllers.NewResultSet: m.resultSet = msg.ResultSet m.updateTable() m.updateViewportToSelectedMessage() + + // Shared events + case events.Error: + m.message = "Error: " + msg.Error() + case events.Message: + m.message = string(msg) + case events.PromptForInput: + m.textInput.Focus() + m.textInput.SetValue("") + m.pendingInput = &msg + + // Tea events case tea.WindowSizeMsg: - footerHeight := lipgloss.Height(m.footerView()) + fixedViewsHeight := lipgloss.Height(m.headerView()) + lipgloss.Height(m.splitterView()) + lipgloss.Height(m.footerView()) tableHeight := msg.Height / 2 if !m.ready { - m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-footerHeight) + m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-fixedViewsHeight) m.viewport.SetContent("(no message selected)") m.ready = true } else { m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - tableHeight - footerHeight + m.viewport.Height = msg.Height - tableHeight - fixedViewsHeight } m.tableWidth, m.tableHeight = msg.Width, tableHeight @@ -129,15 +159,21 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: + // If text input in focus, allow that to accept input messages + if m.pendingInput != nil { + switch msg.String() { + case "ctrl+c", "esc": + m.pendingInput = nil + case "enter": + m.dispatcher.Start(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone) + m.pendingInput = nil + default: + m.textInput, textInputCommands = m.textInput.Update(msg) + } + break + } + switch msg.String() { - case "s": - m.startOperation("Scanning...", func(ctx context.Context) (tea.Msg, error) { - resultSet, err := m.tableService.Scan(ctx, m.tableName) - if err != nil { - return nil, err - } - return newResultSet{resultSet}, nil - }) case "ctrl+c", "q": return m, tea.Quit case "up", "i": @@ -146,7 +182,17 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "down", "k": m.table.GoDown() m.updateViewportToSelectedMessage() + + // TODO: these should be moved somewhere else + case "s": + m.dispatcher.Start(context.Background(), m.tableReadController.Scan()) + case "D": + if selectedItem, ok := m.selectedItem(); ok { + m.dispatcher.Start(context.Background(), m.tableWriteController.Delete(selectedItem.item)) + } } + default: + m.textInput, textInputCommands = m.textInput.Update(msg) } updatedTable, tableMsgs := m.table.Update(msg) @@ -155,21 +201,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table = updatedTable m.viewport = updatedViewport - return m, tea.Batch(tableMsgs, viewportMsgs) -} - -// TODO: this should probably be a separate service -func (m *uiModel) startOperation(msg string, op func(ctx context.Context) (tea.Msg, error)) { - m.message = msg - go func() { - resMsg, err := op(context.Background()) - if err != nil { - m.msgPublisher.Send(errorRaised(err)) - } else if resMsg != nil { - m.msgPublisher.Send(resMsg) - } - m.msgPublisher.Send(setStatusMessage("")) - }() + return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs) } func (m uiModel) View() string { @@ -177,9 +209,35 @@ func (m uiModel) View() string { return "Initializing" } - log.Println("Returning full view") - return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.viewport.View(), m.footerView()) - //return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.footerView()) + if m.pendingInput != nil { + return lipgloss.JoinVertical(lipgloss.Top, + m.headerView(), + m.table.View(), + m.splitterView(), + m.viewport.View(), + m.textInput.View(), + ) + } + + return lipgloss.JoinVertical(lipgloss.Top, + m.headerView(), + m.table.View(), + m.splitterView(), + m.viewport.View(), + m.footerView(), + ) +} + +func (m uiModel) headerView() string { + title := headerStyle.Render("Table: XXX") + line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) +} + +func (m uiModel) splitterView() string { + title := headerStyle.Render("Item") + line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) } func (m uiModel) footerView() string { diff --git a/internal/sqs-browse/controllers/forward.go b/internal/sqs-browse/controllers/forward.go new file mode 100644 index 0000000..8e538e7 --- /dev/null +++ b/internal/sqs-browse/controllers/forward.go @@ -0,0 +1,39 @@ +package controllers + +import ( + "context" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/sqs-browse/models" + "github.com/lmika/awstools/internal/sqs-browse/services/messages" + "github.com/pkg/errors" +) + +type MessageSendingController struct { + messageService *messages.Service + targetQueue string +} + +func NewMessageSendingController(messageService *messages.Service, targetQueue string) *MessageSendingController { + return &MessageSendingController{ + messageService: messageService, + targetQueue: targetQueue, + } +} + +func (msh *MessageSendingController) ForwardMessage(message models.Message) uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + + if msh.targetQueue == "" { + return errors.New("target queue not set") + } + + messageId, err := msh.messageService.SendTo(ctx, message, msh.targetQueue) + if err != nil { + return errors.Wrapf(err, "cannot send message to %v", msh.targetQueue) + } + + uiCtx.Message("Message sent to " + msh.targetQueue + ", id = " + messageId) + return nil + }) +} diff --git a/internal/sqs-browse/providers/sqs/provider.go b/internal/sqs-browse/providers/sqs/provider.go index 76639c6..77c166b 100644 --- a/internal/sqs-browse/providers/sqs/provider.go +++ b/internal/sqs-browse/providers/sqs/provider.go @@ -19,6 +19,20 @@ func NewProvider(client *sqs.Client) *Provider { return &Provider{client: client} } +func (p *Provider) SendMessage(ctx context.Context, msg models.Message, queue string) (string, error) { + // TEMP :: queue URL + + out, err := p.client.SendMessage(ctx, &sqs.SendMessageInput{ + QueueUrl: aws.String(queue), + MessageBody: aws.String(msg.Data), + }) + if err != nil { + return "", errors.Wrapf(err, "unable to send message to %v", queue) + } + + return aws.ToString(out.MessageId), nil +} + func (p *Provider) PollForNewMessages(ctx context.Context, queue string) ([]*models.Message, error) { out, err := p.client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: aws.String(queue), diff --git a/internal/sqs-browse/services/messages/iface.go b/internal/sqs-browse/services/messages/iface.go index 7efd351..bd8beaa 100644 --- a/internal/sqs-browse/services/messages/iface.go +++ b/internal/sqs-browse/services/messages/iface.go @@ -6,5 +6,5 @@ import ( ) type MessageSender interface { - SendMessage(ctx context.Context, msg models.Message, queue string) error + SendMessage(ctx context.Context, msg models.Message, queue string) (string, error) } \ No newline at end of file diff --git a/internal/sqs-browse/services/messages/service.go b/internal/sqs-browse/services/messages/service.go index e9ffc7c..433239f 100644 --- a/internal/sqs-browse/services/messages/service.go +++ b/internal/sqs-browse/services/messages/service.go @@ -10,10 +10,16 @@ type Service struct { messageSender MessageSender } -func NewService() *Service { - return &Service{} +func NewService(messageSender MessageSender) *Service { + return &Service{ + messageSender: messageSender, + } } -func (s *Service) SendTo(ctx context.Context, msg models.Message, destQueue string) error { - return errors.Wrapf(s.messageSender.SendMessage(ctx, msg, destQueue), "cannot send message to %v", destQueue) +func (s *Service) SendTo(ctx context.Context, msg models.Message, destQueue string) (string, error) { + messageId, err := s.messageSender.SendMessage(ctx, msg, destQueue) + if err != nil { + return "", errors.Wrapf(err, "cannot send message to %v", destQueue) + } + return messageId, nil } diff --git a/internal/sqs-browse/ui/model.go b/internal/sqs-browse/ui/model.go index 8c724bb..eb6cc07 100644 --- a/internal/sqs-browse/ui/model.go +++ b/internal/sqs-browse/ui/model.go @@ -1,14 +1,28 @@ package ui import ( + "context" table "github.com/calyptia/go-bubble-table" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/sqs-browse/controllers" + "github.com/lmika/awstools/internal/sqs-browse/models" "log" "strings" ) +var ( + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#eac610")) +) + type uiModel struct { table table.Model viewport viewport.Model @@ -16,17 +30,28 @@ type uiModel struct { ready bool tableRows []table.Row message string + + pendingInput *events.PromptForInput + textInput textinput.Model + + dispatcher *dispatcher.Dispatcher + msgSendingHandlers *controllers.MessageSendingController } -func NewModel() tea.Model { +func NewModel(dispatcher *dispatcher.Dispatcher, msgSendingHandlers *controllers.MessageSendingController) tea.Model { tbl := table.New([]string{"seq", "message"}, 100, 20) rows := make([]table.Row, 0) tbl.SetRows(rows) + textInput := textinput.New() + model := uiModel{ - table: tbl, - tableRows: rows, - message: "", + table: tbl, + tableRows: rows, + message: "", + textInput: textInput, + msgSendingHandlers: msgSendingHandlers, + dispatcher: dispatcher, } return model @@ -37,23 +62,37 @@ func (m uiModel) Init() tea.Cmd { } func (m *uiModel) updateViewportToSelectedMessage() { - if !m.ready { - return - } - - if len(m.tableRows) == 0 { - return - } - - if message, ok := m.table.SelectedRow().(messageTableRow); ok { + if message, ok := m.selectedMessage(); ok { m.viewport.SetContent(message.Data) } else { m.viewport.SetContent("(no message selected)") } } +func (m uiModel) selectedMessage() (models.Message, bool) { + if m.ready && len(m.tableRows) > 0 { + if message, ok := m.table.SelectedRow().(messageTableRow); ok { + return models.Message(message), true + } + } + return models.Message{}, false +} + func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var textInputCommands tea.Cmd + switch msg := msg.(type) { + // Shared messages + case events.Error: + m.message = "Error: " + msg.Error() + case events.Message: + m.message = string(msg) + case events.PromptForInput: + m.textInput.Focus() + m.textInput.SetValue("") + m.pendingInput = &msg + + // Local messages case NewMessagesEvent: for _, newMsg := range msg { m.tableRows = append(m.tableRows, messageTableRow(*newMsg)) @@ -62,13 +101,13 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateViewportToSelectedMessage() case tea.WindowSizeMsg: - footerHeight := lipgloss.Height(m.footerView()) + fixedViewsHeight := lipgloss.Height(m.headerView()) + lipgloss.Height(m.splitterView()) + lipgloss.Height(m.footerView()) if !m.ready { tableHeight := msg.Height / 2 m.table.SetSize(msg.Width, tableHeight) - m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-footerHeight) + m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-fixedViewsHeight) m.viewport.SetContent("(no message selected)") m.ready = true log.Println("Viewport is now ready") @@ -77,12 +116,29 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table.SetSize(msg.Width, tableHeight) m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - tableHeight - footerHeight - //m.viewport.YPosition = tableHeight + m.viewport.Height = msg.Height - tableHeight - fixedViewsHeight } + m.textInput.Width = msg.Width + + m.textInput, textInputCommands = m.textInput.Update(msg) case tea.KeyMsg: + // If text input in focus, allow that to accept input messages + if m.pendingInput != nil { + switch msg.String() { + case "ctrl+c", "esc": + m.pendingInput = nil + case "enter": + m.dispatcher.Start(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone) + m.pendingInput = nil + default: + m.textInput, textInputCommands = m.textInput.Update(msg) + } + break + } + + // Normal focus switch msg.String() { case "ctrl+c", "q": @@ -93,7 +149,15 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "down", "k": m.table.GoDown() m.updateViewportToSelectedMessage() + + // TODO: these should be moved somewhere else + case "f": + if selectedMessage, ok := m.selectedMessage(); ok { + m.dispatcher.Start(context.Background(), m.msgSendingHandlers.ForwardMessage(selectedMessage)) + } } + default: + m.textInput, textInputCommands = m.textInput.Update(msg) } updatedTable, tableMsgs := m.table.Update(msg) @@ -102,7 +166,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table = updatedTable m.viewport = updatedViewport - return m, tea.Batch(tableMsgs, viewportMsgs) + return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs) } func (m uiModel) View() string { @@ -110,9 +174,35 @@ func (m uiModel) View() string { return "Initializing" } - log.Println("Returning full view") - return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.viewport.View(), m.footerView()) - //return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.footerView()) + if m.pendingInput != nil { + return lipgloss.JoinVertical(lipgloss.Top, + m.headerView(), + m.table.View(), + m.splitterView(), + m.viewport.View(), + m.textInput.View(), + ) + } + + return lipgloss.JoinVertical(lipgloss.Top, + m.headerView(), + m.table.View(), + m.splitterView(), + m.viewport.View(), + m.footerView(), + ) +} + +func (m uiModel) headerView() string { + title := headerStyle.Render("Queue: XXX") + line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) +} + +func (m uiModel) splitterView() string { + title := headerStyle.Render("Message") + line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) } func (m uiModel) footerView() string {