Added mode line

Also rescanning will maintain the current query
This commit is contained in:
Leon Mika 2022-06-22 11:57:12 +10:00
parent 54fab1b1c3
commit 809f9adfea
21 changed files with 197 additions and 72 deletions

View file

@ -43,3 +43,8 @@ func Confirm(prompt string, onYes func() tea.Cmd) tea.Cmd {
type MessageWithStatus interface { type MessageWithStatus interface {
StatusMessage() string StatusMessage() string
} }
type MessageWithMode interface {
MessageWithStatus
ModeMessage() string
}

View file

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

View file

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

View file

@ -10,5 +10,5 @@ type TableReadService interface {
Describe(ctx context.Context, table string) (*models.TableInfo, error) Describe(ctx context.Context, table string) (*models.TableInfo, error)
Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error)
Filter(resultSet *models.ResultSet, filter string) *models.ResultSet Filter(resultSet *models.ResultSet, filter string) *models.ResultSet
ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, queryExpr string) (*models.ResultSet, error) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, query models.Queryable) (*models.ResultSet, error)
} }

View file

@ -44,3 +44,19 @@ func (s *State) setResultSetAndFilter(resultSet *models.ResultSet, filter string
s.resultSet = resultSet s.resultSet = resultSet
s.filter = filter s.filter = filter
} }
func (s *State) buildNewResultSetMessage(statusMessage string) NewResultSet {
s.mutex.Lock()
defer s.mutex.Unlock()
var filteredCount int = 0
if s.filter != "" {
for i := range s.resultSet.Items() {
if !s.resultSet.Hidden(i) {
filteredCount += 1
}
}
}
return NewResultSet{s.resultSet, s.filter, filteredCount, statusMessage}
}

View file

@ -3,10 +3,10 @@ package controllers
import ( import (
"context" "context"
"encoding/csv" "encoding/csv"
"fmt"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/lmika/awstools/internal/dynamo-browse/models"
"github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr"
"github.com/pkg/errors" "github.com/pkg/errors"
"os" "os"
"sync" "sync"
@ -81,12 +81,20 @@ func (c *TableReadController) PromptForQuery() tea.Cmd {
Prompt: "query: ", Prompt: "query: ",
OnDone: func(value string) tea.Cmd { OnDone: func(value string) tea.Cmd {
if value == "" { if value == "" {
return c.Rescan() return func() tea.Msg {
resultSet := c.state.ResultSet()
return c.doScan(context.Background(), resultSet, nil)
}
}
expr, err := queryexpr.Parse(value)
if err != nil {
return events.SetError(err)
} }
return func() tea.Msg { return func() tea.Msg {
resultSet := c.state.ResultSet() resultSet := c.state.ResultSet()
newResultSet, err := c.tableService.ScanOrQuery(context.Background(), resultSet.TableInfo, value) newResultSet, err := c.tableService.ScanOrQuery(context.Background(), resultSet.TableInfo, expr)
if err != nil { if err != nil {
return events.Error(err) return events.Error(err)
} }
@ -100,7 +108,8 @@ func (c *TableReadController) PromptForQuery() tea.Cmd {
func (c *TableReadController) Rescan() tea.Cmd { func (c *TableReadController) Rescan() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
return c.doScan(context.Background(), c.state.ResultSet()) resultSet := c.state.ResultSet()
return c.doScan(context.Background(), resultSet, resultSet.Query)
} }
} }
@ -139,8 +148,8 @@ func (c *TableReadController) ExportCSV(filename string) tea.Cmd {
} }
} }
func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet) tea.Msg { func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable) tea.Msg {
newResultSet, err := c.tableService.Scan(ctx, resultSet.TableInfo) newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query)
if err != nil { if err != nil {
return events.Error(err) return events.Error(err)
} }
@ -152,21 +161,7 @@ func (c *TableReadController) doScan(ctx context.Context, resultSet *models.Resu
func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg { func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg {
c.state.setResultSetAndFilter(resultSet, filter) c.state.setResultSetAndFilter(resultSet, filter)
return c.state.buildNewResultSetMessage("")
var statusMessage string
if filter != "" {
var filteredCount int
for i := range resultSet.Items() {
if !resultSet.Hidden(i) {
filteredCount += 1
}
}
statusMessage = fmt.Sprintf("%d of %d items returned", filteredCount, len(resultSet.Items()))
} else {
statusMessage = fmt.Sprintf("%d items returned", len(resultSet.Items()))
}
return NewResultSet{resultSet, statusMessage}
} }
func (c *TableReadController) Unmark() tea.Cmd { func (c *TableReadController) Unmark() tea.Cmd {

View file

@ -61,7 +61,7 @@ func (twc *TableWriteController) NewItem() tea.Cmd {
Dirty: true, Dirty: true,
}) })
}) })
return NewResultSet{twc.state.ResultSet(), "New item added"} return twc.state.buildNewResultSetMessage("New item added")
} }
return keyPrompts.next() return keyPrompts.next()
@ -161,7 +161,7 @@ func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Cmd {
return events.Error(err) return events.Error(err)
} }
return twc.tableReadControllers.doScan(ctx, resultSet) return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query)
} }
}, },
} }
@ -190,7 +190,7 @@ func (twc *TableWriteController) DeleteMarked() tea.Cmd {
return events.Error(err) return events.Error(err)
} }
return twc.tableReadControllers.doScan(ctx, resultSet) return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query)
} }
}, },
} }

View file

@ -2,11 +2,17 @@ package models
type ResultSet struct { type ResultSet struct {
TableInfo *TableInfo TableInfo *TableInfo
Query Queryable
Columns []string Columns []string
items []Item items []Item
attributes []ItemAttribute attributes []ItemAttribute
} }
type Queryable interface {
String() string
Plan(tableInfo *TableInfo) (*QueryExecutionPlan, error)
}
type ItemAttribute struct { type ItemAttribute struct {
Marked bool Marked bool
Hidden bool Hidden bool

View file

@ -16,7 +16,7 @@ type astBinOp struct {
} }
type astLiteralValue struct { type astLiteralValue struct {
String string `parser:"@String"` StringVal string `parser:"@String"`
} }
var parser = participle.MustBuild(&astExpr{}) var parser = participle.MustBuild(&astExpr{})

View file

@ -6,6 +6,10 @@ type QueryExpr struct {
ast *astExpr ast *astExpr
} }
func (md *QueryExpr) BuildQuery(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) { func (md *QueryExpr) Plan(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) {
return md.ast.calcQuery(tableInfo) return md.ast.calcQuery(tableInfo)
} }
func (md *QueryExpr) String() string {
return md.ast.String()
}

View file

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

View file

@ -8,7 +8,7 @@ import (
) )
func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) {
s, err := strconv.Unquote(a.String) s, err := strconv.Unquote(a.StringVal)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "cannot unquote string") return nil, errors.Wrap(err, "cannot unquote string")
} }
@ -16,7 +16,7 @@ func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) {
} }
func (a *astLiteralValue) goValue() (any, error) { func (a *astLiteralValue) goValue() (any, error) {
s, err := strconv.Unquote(a.String) s, err := strconv.Unquote(a.StringVal)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "cannot unquote string") return nil, errors.Wrap(err, "cannot unquote string")
} }

View file

@ -3,7 +3,6 @@ package tables
import ( import (
"context" "context"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr"
"sort" "sort"
"strings" "strings"
@ -33,7 +32,23 @@ func (s *Service) Scan(ctx context.Context, tableInfo *models.TableInfo) (*model
return s.doScan(ctx, tableInfo, nil) return s.doScan(ctx, tableInfo, nil)
} }
func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, filterExpr *expression.Expression) (*models.ResultSet, error) { func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, expr models.Queryable) (*models.ResultSet, error) {
var filterExpr *expression.Expression
if expr != nil {
plan, err := expr.Plan(tableInfo)
if err != nil {
return nil, err
}
// TEMP
if plan.CanQuery {
return nil, errors.Errorf("queries not yet supported")
}
filterExpr = &plan.Expression
}
results, err := s.provider.ScanItems(ctx, tableInfo.Name, filterExpr, 1000) results, err := s.provider.ScanItems(ctx, tableInfo.Name, filterExpr, 1000)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name) return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name)
@ -76,6 +91,7 @@ func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, filte
resultSet := &models.ResultSet{ resultSet := &models.ResultSet{
TableInfo: tableInfo, TableInfo: tableInfo,
Query: expr,
Columns: columns, Columns: columns,
} }
resultSet.SetItems(results) resultSet.SetItems(results)
@ -107,23 +123,8 @@ func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items
return nil return nil
} }
func (s *Service) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, queryExpr string) (*models.ResultSet, error) { func (s *Service) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, expr models.Queryable) (*models.ResultSet, error) {
expr, err := queryexpr.Parse(queryExpr) return s.doScan(ctx, tableInfo, expr)
if err != nil {
return nil, err
}
plan, err := expr.BuildQuery(tableInfo)
if err != nil {
return nil, err
}
// TEMP
if plan.CanQuery {
return nil, errors.Errorf("queries not yet supported")
}
return s.doScan(ctx, tableInfo, &plan.Expression)
} }
// TODO: move into a new service // TODO: move into a new service

View file

@ -9,6 +9,7 @@ import (
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamotableview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamotableview"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/tableselect" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/tableselect"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -25,10 +26,12 @@ type Model struct {
} }
func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteController, cc *commandctrl.CommandController) Model { func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteController, cc *commandctrl.CommandController) Model {
dtv := dynamotableview.New() uiStyles := styles.DefaultStyles
div := dynamoitemview.New()
statusAndPrompt := statusandprompt.New(layout.NewVBox(layout.LastChildFixedAt(17), dtv, div), "") dtv := dynamotableview.New(uiStyles)
tableSelect := tableselect.New(statusAndPrompt) div := dynamoitemview.New(uiStyles)
statusAndPrompt := statusandprompt.New(layout.NewVBox(layout.LastChildFixedAt(17), dtv, div), "", uiStyles.StatusAndPrompt)
tableSelect := tableselect.New(statusAndPrompt, uiStyles)
cc.AddCommands(&commandctrl.CommandContext{ cc.AddCommands(&commandctrl.CommandContext{
Commands: map[string]commandctrl.Command{ Commands: map[string]commandctrl.Command{

View file

@ -3,6 +3,7 @@ package dynamoitemview
import ( import (
"fmt" "fmt"
"github.com/lmika/awstools/internal/dynamo-browse/models/itemrender" "github.com/lmika/awstools/internal/dynamo-browse/models/itemrender"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles"
"io" "io"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
@ -38,9 +39,9 @@ type Model struct {
selectedItem models.Item selectedItem models.Item
} }
func New() *Model { func New(uiStyles styles.Styles) *Model {
return &Model{ return &Model{
frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle), frameTitle: frame.NewFrameTitle("Item", false, uiStyles.Frames),
viewport: viewport.New(100, 100), viewport: viewport.New(100, 100),
} }
} }

View file

@ -9,6 +9,7 @@ import (
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles"
table "github.com/lmika/go-bubble-table" table "github.com/lmika/go-bubble-table"
) )
@ -54,12 +55,12 @@ func (cm columnModel) Header(index int) string {
return cm.m.resultSet.Columns[cm.m.colOffset+index] return cm.m.resultSet.Columns[cm.m.colOffset+index]
} }
func New() *Model { func New(uiStyles styles.Styles) *Model {
tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100) tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100)
rows := make([]table.Row, 0) rows := make([]table.Row, 0)
tbl.SetRows(rows) tbl.SetRows(rows)
frameTitle := frame.NewFrameTitle("No table", true, activeHeaderStyle) frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames)
return &Model{ return &Model{
frameTitle: frameTitle, frameTitle: frameTitle,

View file

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

View file

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

View file

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

View file

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