diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go index 19857b4..ef82c2f 100644 --- a/internal/common/ui/events/commands.go +++ b/internal/common/ui/events/commands.go @@ -43,3 +43,8 @@ func Confirm(prompt string, onYes func() tea.Cmd) tea.Cmd { type MessageWithStatus interface { StatusMessage() string } + +type MessageWithMode interface { + MessageWithStatus + ModeMessage() string +} diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go index 9688142..9aa3388 100644 --- a/internal/common/ui/events/errors.go +++ b/internal/common/ui/events/errors.go @@ -10,6 +10,9 @@ type ErrorMsg error // Message indicates that a message should be shown to the user type StatusMsg string +// ModeMessage indicates that the mode should be changed to the following +type ModeMessage string + // PromptForInput indicates that the context is requesting a line of input type PromptForInputMsg struct { Prompt string diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index a460f5d..c8a3713 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -1,18 +1,43 @@ package controllers import ( + "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/dynamo-browse/models" ) type NewResultSet struct { ResultSet *models.ResultSet + currentFilter string + filteredCount int statusMessage string } +func (rs NewResultSet) ModeMessage() string { + var modeLine string + + if rs.ResultSet.Query != nil { + modeLine = rs.ResultSet.Query.String() + } else { + modeLine = "All results" + } + + if rs.currentFilter != "" { + modeLine = fmt.Sprintf("%v - Filter: '%v'", modeLine, rs.currentFilter) + } + return modeLine +} + func (rs NewResultSet) StatusMessage() string { - //return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items())) - return rs.statusMessage + if rs.statusMessage != "" { + return rs.statusMessage + } + + if rs.currentFilter != "" { + return fmt.Sprintf("%d of %d items returned", rs.filteredCount, len(rs.ResultSet.Items())) + } else { + return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items())) + } } type SetReadWrite struct { diff --git a/internal/dynamo-browse/controllers/iface.go b/internal/dynamo-browse/controllers/iface.go index fcd4af1..bf61064 100644 --- a/internal/dynamo-browse/controllers/iface.go +++ b/internal/dynamo-browse/controllers/iface.go @@ -10,5 +10,5 @@ type TableReadService interface { Describe(ctx context.Context, table string) (*models.TableInfo, error) Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) Filter(resultSet *models.ResultSet, filter string) *models.ResultSet - ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, queryExpr string) (*models.ResultSet, error) + ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, query models.Queryable) (*models.ResultSet, error) } diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go index 3518e6b..f94893c 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -44,3 +44,19 @@ func (s *State) setResultSetAndFilter(resultSet *models.ResultSet, filter string s.resultSet = resultSet s.filter = filter } + +func (s *State) buildNewResultSetMessage(statusMessage string) NewResultSet { + s.mutex.Lock() + defer s.mutex.Unlock() + + var filteredCount int = 0 + if s.filter != "" { + for i := range s.resultSet.Items() { + if !s.resultSet.Hidden(i) { + filteredCount += 1 + } + } + } + + return NewResultSet{s.resultSet, s.filter, filteredCount, statusMessage} +} diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index c07d208..92dbc33 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -3,10 +3,10 @@ package controllers import ( "context" "encoding/csv" - "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr" "github.com/pkg/errors" "os" "sync" @@ -81,12 +81,20 @@ func (c *TableReadController) PromptForQuery() tea.Cmd { Prompt: "query: ", OnDone: func(value string) tea.Cmd { 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 { 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 { return events.Error(err) } @@ -100,7 +108,8 @@ func (c *TableReadController) PromptForQuery() tea.Cmd { func (c *TableReadController) Rescan() tea.Cmd { 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 { - newResultSet, err := c.tableService.Scan(ctx, resultSet.TableInfo) +func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable) tea.Msg { + newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query) if err != nil { return events.Error(err) } @@ -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 { c.state.setResultSetAndFilter(resultSet, filter) - - 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} + return c.state.buildNewResultSetMessage("") } func (c *TableReadController) Unmark() tea.Cmd { diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 60ea800..c155e80 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -61,7 +61,7 @@ func (twc *TableWriteController) NewItem() tea.Cmd { Dirty: true, }) }) - return NewResultSet{twc.state.ResultSet(), "New item added"} + return twc.state.buildNewResultSetMessage("New item added") } return keyPrompts.next() @@ -161,7 +161,7 @@ func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Cmd { 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 twc.tableReadControllers.doScan(ctx, resultSet) + return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query) } }, } diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index 40e4f31..0043691 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -2,11 +2,17 @@ package models type ResultSet struct { TableInfo *TableInfo + Query Queryable Columns []string items []Item attributes []ItemAttribute } +type Queryable interface { + String() string + Plan(tableInfo *TableInfo) (*QueryExecutionPlan, error) +} + type ItemAttribute struct { Marked bool Hidden bool diff --git a/internal/dynamo-browse/models/queryexpr/ast.go b/internal/dynamo-browse/models/queryexpr/ast.go index 4750028..d8c9912 100644 --- a/internal/dynamo-browse/models/queryexpr/ast.go +++ b/internal/dynamo-browse/models/queryexpr/ast.go @@ -16,7 +16,7 @@ type astBinOp struct { } type astLiteralValue struct { - String string `parser:"@String"` + StringVal string `parser:"@String"` } var parser = participle.MustBuild(&astExpr{}) diff --git a/internal/dynamo-browse/models/queryexpr/astquery.go b/internal/dynamo-browse/models/queryexpr/calcquery.go similarity index 100% rename from internal/dynamo-browse/models/queryexpr/astquery.go rename to internal/dynamo-browse/models/queryexpr/calcquery.go diff --git a/internal/dynamo-browse/models/queryexpr/expr.go b/internal/dynamo-browse/models/queryexpr/expr.go index 128e1df..2916c15 100644 --- a/internal/dynamo-browse/models/queryexpr/expr.go +++ b/internal/dynamo-browse/models/queryexpr/expr.go @@ -6,6 +6,10 @@ type QueryExpr struct { 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) } + +func (md *QueryExpr) String() string { + return md.ast.String() +} diff --git a/internal/dynamo-browse/models/queryexpr/tostr.go b/internal/dynamo-browse/models/queryexpr/tostr.go new file mode 100644 index 0000000..9453954 --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/tostr.go @@ -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 +} diff --git a/internal/dynamo-browse/models/queryexpr/values.go b/internal/dynamo-browse/models/queryexpr/values.go index d6b3739..8bb0e81 100644 --- a/internal/dynamo-browse/models/queryexpr/values.go +++ b/internal/dynamo-browse/models/queryexpr/values.go @@ -8,7 +8,7 @@ import ( ) func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { - s, err := strconv.Unquote(a.String) + s, err := strconv.Unquote(a.StringVal) if err != nil { 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) { - s, err := strconv.Unquote(a.String) + s, err := strconv.Unquote(a.StringVal) if err != nil { return nil, errors.Wrap(err, "cannot unquote string") } diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index d58aaad..50f02eb 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -3,7 +3,6 @@ package tables import ( "context" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" - "github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr" "sort" "strings" @@ -33,7 +32,23 @@ func (s *Service) Scan(ctx context.Context, tableInfo *models.TableInfo) (*model 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) if err != nil { 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{ TableInfo: tableInfo, + Query: expr, Columns: columns, } resultSet.SetItems(results) @@ -107,23 +123,8 @@ func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items return nil } -func (s *Service) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, queryExpr string) (*models.ResultSet, error) { - expr, err := queryexpr.Parse(queryExpr) - 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) +func (s *Service) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, expr models.Queryable) (*models.ResultSet, error) { + return s.doScan(ctx, tableInfo, expr) } // TODO: move into a new service diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index b04a327..e16ed4e 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -9,6 +9,7 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamotableview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/tableselect" "github.com/pkg/errors" ) @@ -25,10 +26,12 @@ type Model struct { } func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteController, cc *commandctrl.CommandController) Model { - dtv := dynamotableview.New() - div := dynamoitemview.New() - statusAndPrompt := statusandprompt.New(layout.NewVBox(layout.LastChildFixedAt(17), dtv, div), "") - tableSelect := tableselect.New(statusAndPrompt) + uiStyles := styles.DefaultStyles + + dtv := dynamotableview.New(uiStyles) + 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{ Commands: map[string]commandctrl.Command{ diff --git a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go index bcc65cf..1ac4440 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go @@ -3,6 +3,7 @@ package dynamoitemview import ( "fmt" "github.com/lmika/awstools/internal/dynamo-browse/models/itemrender" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" "io" "strings" "text/tabwriter" @@ -38,9 +39,9 @@ type Model struct { selectedItem models.Item } -func New() *Model { +func New(uiStyles styles.Styles) *Model { return &Model{ - frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle), + frameTitle: frame.NewFrameTitle("Item", false, uiStyles.Frames), viewport: viewport.New(100, 100), } } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index fbdfdb4..1e9f187 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -9,6 +9,7 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" table "github.com/lmika/go-bubble-table" ) @@ -54,12 +55,12 @@ func (cm columnModel) Header(index int) string { 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) rows := make([]table.Row, 0) tbl.SetRows(rows) - frameTitle := frame.NewFrameTitle("No table", true, activeHeaderStyle) + frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames) return &Model{ frameTitle: frameTitle, diff --git a/internal/dynamo-browse/ui/teamodels/frame/frame.go b/internal/dynamo-browse/ui/teamodels/frame/frame.go index 7ce9ba6..e2da798 100644 --- a/internal/dynamo-browse/ui/teamodels/frame/frame.go +++ b/internal/dynamo-browse/ui/teamodels/frame/frame.go @@ -15,14 +15,19 @@ var ( // Frame is a frame that appears in the type FrameTitle struct { - header string - active bool - activeStyle lipgloss.Style - width int + header string + active bool + style Style + width int } -func NewFrameTitle(header string, active bool, activeStyle lipgloss.Style) FrameTitle { - return FrameTitle{header, active, activeStyle, 0} +type Style struct { + ActiveTitle lipgloss.Style + InactiveTitle lipgloss.Style +} + +func NewFrameTitle(header string, active bool, style Style) FrameTitle { + return FrameTitle{header, active, style, 0} } func (f *FrameTitle) SetTitle(title string) { @@ -42,9 +47,9 @@ func (f FrameTitle) HeaderHeight() int { } func (f FrameTitle) headerView() string { - style := inactiveHeaderStyle + style := f.style.InactiveTitle if f.active { - style = f.activeStyle + style = f.style.ActiveTitle } titleText := f.header diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go index 380896b..a290750 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go @@ -12,15 +12,21 @@ import ( // event is received, focus will be torn away and the user will be given a prompt the enter text. type StatusAndPrompt struct { model layout.ResizingModel + style Style + modeLine string statusMessage string pendingInput *events.PromptForInputMsg textInput textinput.Model width int } -func New(model layout.ResizingModel, initialMsg string) *StatusAndPrompt { +type Style struct { + ModeLine lipgloss.Style +} + +func New(model layout.ResizingModel, initialMsg string, style Style) *StatusAndPrompt { textInput := textinput.New() - return &StatusAndPrompt{model: model, statusMessage: initialMsg, textInput: textInput} + return &StatusAndPrompt{model: model, style: style, statusMessage: initialMsg, modeLine: "", textInput: textInput} } func (s *StatusAndPrompt) Init() tea.Cmd { @@ -33,7 +39,12 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.statusMessage = "Error: " + msg.Error() case events.StatusMsg: s.statusMessage = string(msg) + case events.ModeMessage: + s.modeLine = string(msg) case events.MessageWithStatus: + if hasModeMessage, ok := msg.(events.MessageWithMode); ok { + s.modeLine = hasModeMessage.ModeMessage() + } s.statusMessage = msg.StatusMessage() case events.PromptForInputMsg: if s.pendingInput != nil { @@ -87,8 +98,14 @@ func (s *StatusAndPrompt) Resize(w, h int) layout.ResizingModel { } func (s *StatusAndPrompt) viewStatus() string { + modeLine := s.style.ModeLine.Render(lipgloss.PlaceHorizontal(s.width, lipgloss.Left, s.modeLine, lipgloss.WithWhitespaceChars(" "))) + + var statusLine string if s.pendingInput != nil { - return s.textInput.View() + statusLine = s.textInput.View() + } else { + statusLine = s.statusMessage } - return s.statusMessage + + return lipgloss.JoinVertical(lipgloss.Top, modeLine, statusLine) } diff --git a/internal/dynamo-browse/ui/teamodels/styles/styles.go b/internal/dynamo-browse/ui/teamodels/styles/styles.go new file mode 100644 index 0000000..10caf45 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/styles/styles.go @@ -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")), + }, +} diff --git a/internal/dynamo-browse/ui/teamodels/tableselect/model.go b/internal/dynamo-browse/ui/teamodels/tableselect/model.go index 1feebe7..8f06a97 100644 --- a/internal/dynamo-browse/ui/teamodels/tableselect/model.go +++ b/internal/dynamo-browse/ui/teamodels/tableselect/model.go @@ -7,6 +7,7 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils" ) @@ -26,8 +27,8 @@ type Model struct { w, h int } -func New(submodel tea.Model) *Model { - frameTitle := frame.NewFrameTitle("Select table", false, activeHeaderStyle) +func New(submodel tea.Model, uiStyles styles.Styles) *Model { + frameTitle := frame.NewFrameTitle("Select table", false, uiStyles.Frames) return &Model{frameTitle: frameTitle, submodel: submodel} }