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
This commit is contained in:
Leon Mika 2022-03-23 15:40:31 +11:00
parent 1969504611
commit 7526c095ee
24 changed files with 602 additions and 97 deletions

View file

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

View file

@ -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)
}
}
}
type msgLoopback struct {
program *tea.Program
}
func (m *msgLoopback) Send(msg tea.Msg) {
m.program.Send(msg)
}

1
go.mod
View file

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

1
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"],
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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