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:
parent
1969504611
commit
7526c095ee
|
@ -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
|
||||
|
||||
|
|
|
@ -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
1
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
|
||||
|
|
1
go.sum
1
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=
|
||||
|
|
30
internal/common/ui/dispatcher/context.go
Normal file
30
internal/common/ui/dispatcher/context.go
Normal 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,
|
||||
})
|
||||
}
|
49
internal/common/ui/dispatcher/dispatcher.go
Normal file
49
internal/common/ui/dispatcher/dispatcher.go
Normal 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
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
|
7
internal/common/ui/dispatcher/iface.go
Normal file
7
internal/common/ui/dispatcher/iface.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package dispatcher
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type MessagePublisher interface {
|
||||
Send(msg tea.Msg)
|
||||
}
|
16
internal/common/ui/events/errors.go
Normal file
16
internal/common/ui/events/errors.go
Normal 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
|
||||
}
|
15
internal/common/ui/uimodels/context.go
Normal file
15
internal/common/ui/uimodels/context.go
Normal 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)
|
||||
}
|
10
internal/common/ui/uimodels/iface.go
Normal file
10
internal/common/ui/uimodels/iface.go
Normal 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)
|
||||
}
|
14
internal/common/ui/uimodels/operations.go
Normal file
14
internal/common/ui/uimodels/operations.go
Normal 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)
|
||||
}
|
||||
|
15
internal/common/ui/uimodels/promptvalue.go
Normal file
15
internal/common/ui/uimodels/promptvalue.go
Normal 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)
|
||||
}
|
|
@ -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
|
44
internal/dynamo-browse/controllers/tableread.go
Normal file
44
internal/dynamo-browse/controllers/tableread.go
Normal 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
|
||||
}
|
53
internal/dynamo-browse/controllers/tablewrite.go
Normal file
53
internal/dynamo-browse/controllers/tablewrite.go
Normal 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
|
||||
})
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"],
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
39
internal/sqs-browse/controllers/forward.go
Normal file
39
internal/sqs-browse/controllers/forward.go
Normal 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
|
||||
})
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue