diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index ff5b95d..3427e2d 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -44,24 +44,10 @@ func main() { tableService := tables.NewService(dynamoProvider) tableReadController := controllers.NewTableReadController(tableService, *flagTable) - tableWriteController := controllers.NewTableWriteController(tableService, tableReadController, *flagTable) - _ = tableWriteController + tableWriteController := controllers.NewTableWriteController(tableService, tableReadController) commandController := commandctrl.NewCommandController() - commandController.AddCommands(&commandctrl.CommandContext{ - Commands: map[string]commandctrl.Command{ - "q": commandctrl.NoArgCommand(tea.Quit), - "table": func(args []string) tea.Cmd { - if len(args) == 0 { - return tableReadController.ListTables() - } else { - return tableReadController.ScanTable(args[0]) - } - }, - }, - }) - - model := ui.NewModel(tableReadController, commandController) + model := ui.NewModel(tableReadController, tableWriteController, commandController) // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. lipgloss.HasDarkBackground() diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index 0d05b3a..cd07142 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -23,3 +23,5 @@ type PromptForTableMsg struct { Tables []string OnSelected func(tableName string) tea.Cmd } + +type ResultSetUpdated struct{} diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 1ff1494..5e42eae 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -72,17 +72,26 @@ func (c *TableReadController) ScanTable(name string) tea.Cmd { func (c *TableReadController) Rescan() tea.Cmd { return func() tea.Msg { - ctx := context.Background() - - resultSet, err := c.tableService.Scan(ctx, c.resultSet.TableInfo) - if err != nil { - return events.Error(err) - } - - return c.setResultSet(resultSet) + return c.doScan(context.Background(), c.resultSet) } } +func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet) tea.Msg { + newResultSet, err := c.tableService.Scan(ctx, resultSet.TableInfo) + if err != nil { + return events.Error(err) + } + + return c.setResultSet(newResultSet) +} + +func (c *TableReadController) ResultSet() *models.ResultSet { + c.mutex.Lock() + defer c.mutex.Unlock() + + return c.resultSet +} + func (c *TableReadController) setResultSet(resultSet *models.ResultSet) tea.Msg { c.mutex.Lock() defer c.mutex.Unlock() @@ -90,51 +99,3 @@ func (c *TableReadController) setResultSet(resultSet *models.ResultSet) tea.Msg c.resultSet = resultSet return NewResultSet{resultSet} } - -/* -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) (err error) { - uiCtx := uimodels.Ctx(ctx) - - if !quiet { - uiCtx.Message("Scanning...") - } - - tableInfo, err := c.tableInfo(ctx) - if err != nil { - return err - } - - resultSet, err := c.tableService.Scan(ctx, tableInfo) - if err != nil { - return err - } - - if !quiet { - uiCtx.Messagef("Found %d items", len(resultSet.Items)) - } - uiCtx.Send(NewResultSet{resultSet}) - return nil -} -*/ - -// tableInfo returns the table info from the state if a result set exists. If not, it fetches the -// table information from the service. -// func (c *TableReadController) tableInfo(ctx context.Context) (*models.TableInfo, error) { -// /* -// if existingResultSet := CurrentState(ctx).ResultSet; existingResultSet != nil { -// return existingResultSet.TableInfo, nil -// } -// */ - -// tableInfo, err := c.tableService.Describe(ctx, c.tableName) -// if err != nil { -// return nil, errors.Wrapf(err, "cannot describe %v", c.tableName) -// } -// return tableInfo, nil -// } diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 33d6c10..4b56b5f 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -2,137 +2,58 @@ package controllers import ( "context" - - "github.com/lmika/awstools/internal/common/ui/uimodels" + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" - "github.com/pkg/errors" ) type TableWriteController struct { tableService *tables.Service tableReadControllers *TableReadController - tableName string } -func NewTableWriteController(tableService *tables.Service, tableReadControllers *TableReadController, tableName string) *TableWriteController { +func NewTableWriteController(tableService *tables.Service, tableReadControllers *TableReadController) *TableWriteController { return &TableWriteController{ tableService: tableService, tableReadControllers: tableReadControllers, - tableName: tableName, } } -func (c *TableWriteController) ToggleReadWrite() uimodels.Operation { - return uimodels.OperationFn(func(ctx context.Context) error { - uiCtx := uimodels.Ctx(ctx) - state := CurrentState(ctx) +func (twc *TableWriteController) ToggleMark(idx int) tea.Cmd { + return func() tea.Msg { + resultSet := twc.tableReadControllers.ResultSet() + resultSet.SetMark(idx, !resultSet.Marked(idx)) - if state.InReadWriteMode { - uiCtx.Send(SetReadWrite{NewValue: false}) - uiCtx.Message("read/write mode disabled") - } else { - uiCtx.Send(SetReadWrite{NewValue: true}) - uiCtx.Message("read/write mode enabled") + return ResultSetUpdated{} + } +} + +func (twc *TableWriteController) DeleteMarked() tea.Cmd { + return func() tea.Msg { + resultSet := twc.tableReadControllers.ResultSet() + markedItems := resultSet.MarkedItems() + + if len(markedItems) == 0 { + return events.StatusMsg("no marked items") } - return nil - }) -} - -func (c *TableWriteController) Duplicate() uimodels.Operation { - return nil - /* - return uimodels.OperationFn(func(ctx context.Context) error { - uiCtx := uimodels.Ctx(ctx) - state := CurrentState(ctx) - - if state.SelectedItem == nil { - return errors.New("no selected item") - } else if !state.InReadWriteMode { - return errors.New("not in read/write mode") - } - - uiCtx.Input("Dup: ", uimodels.OperationFn(func(ctx context.Context) error { - modExpr, err := modexpr.Parse(uimodels.PromptValue(ctx)) - if err != nil { - return err + return events.PromptForInputMsg{ + Prompt: fmt.Sprintf("delete %d items? ", len(markedItems)), + OnDone: func(value string) tea.Cmd { + if value != "y" { + return events.SetStatus("operation aborted") } - newItem, err := modExpr.Patch(state.SelectedItem) - if err != nil { - return err + return func() tea.Msg { + ctx := context.Background() + if err := twc.tableService.Delete(ctx, resultSet.TableInfo, markedItems); err != nil { + return events.Error(err) + } + + return twc.tableReadControllers.doScan(ctx, resultSet) } - - // TODO: preview new item - - uiCtx := uimodels.Ctx(ctx) - uiCtx.Input("Put item? ", uimodels.OperationFn(func(ctx context.Context) error { - if uimodels.PromptValue(ctx) != "y" { - return errors.New("operation aborted") - } - - tableInfo, err := c.tableReadControllers.tableInfo(ctx) - if err != nil { - return err - } - - // Delete the item - if err := c.tableService.Put(ctx, tableInfo, newItem); err != nil { - return err - } - - // Rescan to get updated items - // if err := c.tableReadControllers.doScan(ctx, true); err != nil { - // return err - // } - - return nil - })) - return nil - })) - return nil - }) - */ -} - -func (c *TableWriteController) Delete() uimodels.Operation { - return uimodels.OperationFn(func(ctx context.Context) error { - uiCtx := uimodels.Ctx(ctx) - state := CurrentState(ctx) - - if state.SelectedItem == nil { - return errors.New("no selected item") - } else if !state.InReadWriteMode { - return errors.New("not in read/write mode") + }, } - - 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") - } - - /* - tableInfo, err := c.tableReadControllers.tableInfo(ctx) - if err != nil { - return err - } - - // Delete the item - if err := c.tableService.Delete(ctx, tableInfo, state.SelectedItem); err != nil { - return err - } - */ - - // Rescan to get updated items - // if err := c.tableReadControllers.doScan(ctx, true); err != nil { - // return err - // } - - uiCtx.Message("Item deleted") - return nil - })) - return nil - }) + } } diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index 332c2f8..7ec80c4 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -6,6 +6,7 @@ type ResultSet struct { TableInfo *TableInfo Columns []string Items []Item + Marks map[int]bool } type Item map[string]types.AttributeValue @@ -30,3 +31,28 @@ func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue { } return itemKey } + +func (rs *ResultSet) SetMark(idx int, marked bool) { + if marked { + if rs.Marks == nil { + rs.Marks = make(map[int]bool) + } + rs.Marks[idx] = true + } else { + delete(rs.Marks, idx) + } +} + +func (rs *ResultSet) Marked(idx int) bool { + return rs.Marks[idx] +} + +func (rs *ResultSet) MarkedItems() []Item { + items := make([]Item, 0) + for i, marked := range rs.Marks { + if marked { + items = append(items, rs.Items[i]) + } + } + return items +} diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index b052fe1..b1898a6 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -78,6 +78,11 @@ func (s *Service) Put(ctx context.Context, tableInfo *models.TableInfo, item mod return s.provider.PutItem(ctx, tableInfo.Name, item) } -func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, item models.Item) error { - return s.provider.DeleteItem(ctx, tableInfo.Name, item.KeyValue(tableInfo)) +func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items []models.Item) error { + for _, item := range items { + if err := s.provider.DeleteItem(ctx, tableInfo.Name, item.KeyValue(tableInfo)); err != nil { + return errors.Wrapf(err, "cannot delete item") + } + } + return nil } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index b4a8835..f53e051 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -12,28 +12,46 @@ import ( ) type Model struct { - tableReadController *controllers.TableReadController - commandController *commandctrl.CommandController - statusAndPrompt *statusandprompt.StatusAndPrompt - tableSelect *tableselect.Model + tableReadController *controllers.TableReadController + tableWriteController *controllers.TableWriteController + commandController *commandctrl.CommandController + statusAndPrompt *statusandprompt.StatusAndPrompt + tableSelect *tableselect.Model - root tea.Model + root tea.Model + tableView *dynamotableview.Model } -func NewModel(rc *controllers.TableReadController, cc *commandctrl.CommandController) Model { +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) + cc.AddCommands(&commandctrl.CommandContext{ + Commands: map[string]commandctrl.Command{ + "q": commandctrl.NoArgCommand(tea.Quit), + "table": func(args []string) tea.Cmd { + if len(args) == 0 { + return rc.ListTables() + } else { + return rc.ScanTable(args[0]) + } + }, + "delete": commandctrl.NoArgCommand(wc.DeleteMarked()), + }, + }) + root := layout.FullScreen(tableSelect) return Model{ - tableReadController: rc, - commandController: cc, - statusAndPrompt: statusAndPrompt, - tableSelect: tableSelect, - root: root, + tableReadController: rc, + tableWriteController: wc, + commandController: cc, + statusAndPrompt: statusAndPrompt, + tableSelect: tableSelect, + root: root, + tableView: dtv, } } @@ -43,9 +61,13 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case controllers.ResultSetUpdated: + m.tableView.Refresh() case tea.KeyMsg: if !m.statusAndPrompt.InPrompt() && !m.tableSelect.Visible() { switch msg.String() { + case "m": + return m, m.tableWriteController.ToggleMark(m.tableView.SelectedItemIndex()) case "s": return m, m.tableReadController.Rescan() case ":": diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 44a818c..cb23f5b 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -28,8 +28,8 @@ func New() *Model { frameTitle := frame.NewFrameTitle("No table", true) return &Model{ - frameTitle: frameTitle, - table: tbl, + frameTitle: frameTitle, + table: tbl, } } @@ -85,6 +85,10 @@ func (m *Model) updateTable() { m.table = newTbl } +func (m *Model) SelectedItemIndex() int { + return m.table.Cursor() +} + func (m *Model) selectedItem() (itemTableRow, bool) { resultSet := m.resultSet if resultSet != nil && len(resultSet.Items) > 0 { @@ -105,3 +109,8 @@ func (m *Model) postSelectedItemChanged() tea.Msg { return dynamoitemview.NewItemSelected{ResultSet: item.resultSet, Item: item.item} } + +func (m *Model) Refresh() { + m.table.GoDown() + m.table.GoUp() +} diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go index 1137062..6095e40 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go @@ -2,6 +2,7 @@ package dynamotableview import ( "fmt" + "github.com/charmbracelet/lipgloss" "io" "strings" @@ -10,12 +11,19 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/models" ) +var ( + markedRowStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#e1e1e1")) +) + type itemTableRow struct { resultSet *models.ResultSet item models.Item } func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { + isMarked := mtr.resultSet.Marked(index) + sb := strings.Builder{} for i, colName := range mtr.resultSet.Columns { if i > 0 { @@ -34,7 +42,13 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { } } if index == model.Cursor() { - fmt.Fprintln(w, model.Styles.SelectedRow.Render(sb.String())) + style := model.Styles.SelectedRow + if isMarked { + style = style.Copy().Inherit(markedRowStyle) + } + fmt.Fprintln(w, style.Render(sb.String())) + } else if isMarked { + fmt.Fprintln(w, markedRowStyle.Render(sb.String())) } else { fmt.Fprintln(w, sb.String()) } diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go index d4ee696..b422003 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go @@ -7,6 +7,7 @@ import ( "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils" + "log" ) // StatusAndPrompt is a resizing model which displays a submodel and a status bar. When the start prompt @@ -33,7 +34,7 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case events.ErrorMsg: s.statusMessage = "Error: " + msg.Error() case events.StatusMsg: - s.statusMessage = string(s.statusMessage) + s.statusMessage = string(msg) case events.MessageWithStatus: s.statusMessage = msg.StatusMessage() case events.PromptForInputMsg: @@ -46,15 +47,18 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.textInput.Focus() s.textInput.SetValue("") s.pendingInput = &msg + log.Println("pending input == ", s.pendingInput) return s, nil case tea.KeyMsg: if s.pendingInput != nil { switch msg.String() { case "ctrl+c", "esc": s.pendingInput = nil + log.Println("pending input == ", s.pendingInput) case "enter": pendingInput := s.pendingInput s.pendingInput = nil + log.Println("pending input == ", s.pendingInput) return s, pendingInput.OnDone(s.textInput.Value()) }