issue-10: copy item to clipboard

Added key binding to copy selected, or marked, items to clipboard.
This commit is contained in:
Leon Mika 2022-08-20 10:41:32 +10:00
parent f0bd3022cc
commit 90ec88d360
11 changed files with 236 additions and 86 deletions

View file

@ -3,34 +3,47 @@ package controllers
import (
"context"
"encoding/csv"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/audax/internal/common/ui/events"
"github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/models/queryexpr"
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/audax/internal/dynamo-browse/services/workspaces"
"github.com/pkg/errors"
"golang.design/x/clipboard"
"log"
"os"
"strings"
"sync"
)
type TableReadController struct {
tableService TableReadService
workspaceService *workspaces.ViewSnapshotService
tableName string
tableService TableReadService
workspaceService *workspaces.ViewSnapshotService
itemRendererService *itemrenderer.Service
tableName string
// state
mutex *sync.Mutex
state *State
mutex *sync.Mutex
state *State
clipboardInit bool
}
func NewTableReadController(state *State, tableService TableReadService, workspaceService *workspaces.ViewSnapshotService, tableName string) *TableReadController {
func NewTableReadController(
state *State,
tableService TableReadService,
workspaceService *workspaces.ViewSnapshotService,
itemRendererService *itemrenderer.Service,
tableName string,
) *TableReadController {
return &TableReadController{
state: state,
tableService: tableService,
workspaceService: workspaceService,
tableName: tableName,
mutex: new(sync.Mutex),
state: state,
tableService: tableService,
workspaceService: workspaceService,
itemRendererService: itemRendererService,
tableName: tableName,
mutex: new(sync.Mutex),
}
}
@ -254,3 +267,40 @@ func (c *TableReadController) ViewBack() tea.Msg {
tableInfo.Name, viewSnapshot.Query, viewSnapshot.Filter)
return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false)
}
func (c *TableReadController) CopyItemToClipboard(idx int) tea.Msg {
if err := c.initClipboard(); err != nil {
return events.Error(err)
}
itemCount := 0
c.state.withResultSet(func(resultSet *models.ResultSet) {
sb := new(strings.Builder)
_ = applyToMarkedItems(resultSet, idx, func(idx int, item models.Item) error {
if sb.Len() > 0 {
fmt.Fprintln(sb, "---")
}
c.itemRendererService.RenderItem(sb, resultSet.Items()[idx], resultSet, true)
itemCount += 1
return nil
})
clipboard.Write(clipboard.FmtText, []byte(sb.String()))
})
return events.SetStatus(applyToN("", itemCount, "item", "items", " copied to clipboard"))
}
func (c *TableReadController) initClipboard() error {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.clipboardInit {
return nil
}
if err := clipboard.Init(); err != nil {
return errors.Wrap(err, "unable to enable clipboard")
}
c.clipboardInit = true
return nil
}

View file

@ -107,7 +107,7 @@ func (twc *TableWriteController) setStringValue(idx int, attr attrPath) tea.Msg
Prompt: "string value: ",
OnDone: func(value string) tea.Msg {
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
if err := applyToMarkedItems(set, idx, func(idx int, item models.Item) error {
if err := attr.setAt(item, &types.AttributeValueMemberS{Value: value}); err != nil {
return err
}
@ -126,25 +126,12 @@ func (twc *TableWriteController) setStringValue(idx int, attr attrPath) tea.Msg
}
}
func (twc *TableWriteController) applyToItems(rs *models.ResultSet, selectedIndex int, applyFn func(idx int, item models.Item) error) error {
if markedItems := rs.MarkedItems(); len(markedItems) > 0 {
for _, mi := range markedItems {
if err := applyFn(mi.Index, mi.Item); err != nil {
return err
}
}
return nil
}
return applyFn(selectedIndex, rs.Items()[selectedIndex])
}
func (twc *TableWriteController) setNumberValue(idx int, attr attrPath) tea.Msg {
return events.PromptForInputMsg{
Prompt: "number value: ",
OnDone: func(value string) tea.Msg {
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
if err := applyToMarkedItems(set, idx, func(idx int, item models.Item) error {
if err := attr.setAt(item, &types.AttributeValueMemberN{Value: value}); err != nil {
return err
}
@ -173,7 +160,7 @@ func (twc *TableWriteController) setBoolValue(idx int, attr attrPath) tea.Msg {
}
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
if err := applyToMarkedItems(set, idx, func(idx int, item models.Item) error {
if err := attr.setAt(item, &types.AttributeValueMemberBOOL{Value: b}); err != nil {
return err
}
@ -194,7 +181,7 @@ func (twc *TableWriteController) setBoolValue(idx int, attr attrPath) tea.Msg {
func (twc *TableWriteController) setNullValue(idx int, attr attrPath) tea.Msg {
if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
if err := applyToMarkedItems(set, idx, func(idx int, item models.Item) error {
if err := attr.setAt(item, &types.AttributeValueMemberNULL{Value: true}); err != nil {
return err
}

View file

@ -0,0 +1,16 @@
package controllers
import "github.com/lmika/audax/internal/dynamo-browse/models"
func applyToMarkedItems(rs *models.ResultSet, selectedIndex int, applyFn func(idx int, item models.Item) error) error {
if markedItems := rs.MarkedItems(); len(markedItems) > 0 {
for _, mi := range markedItems {
if err := applyFn(mi.Index, mi.Item); err != nil {
return err
}
}
return nil
}
return applyFn(selectedIndex, rs.Items()[selectedIndex])
}

View file

@ -0,0 +1,62 @@
package itemrenderer
import (
"fmt"
"github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/models/itemrender"
"io"
"text/tabwriter"
)
type Service struct {
styles styleRenderer
}
func NewService(fileTypeStyle StyleRenderer, metaInfoStyle StyleRenderer) *Service {
return &Service{
styles: styleRenderer{
fileTypeRenderer: fileTypeStyle,
metaInfoRenderer: metaInfoStyle,
},
}
}
func (s *Service) RenderItem(w io.Writer, item models.Item, resultSet *models.ResultSet, plainText bool) {
styles := s.styles
if plainText {
styles = styleRenderer{plainTextStyleRenderer{}, plainTextStyleRenderer{}}
}
tabWriter := tabwriter.NewWriter(w, 0, 1, 1, ' ', 0)
seenColumns := make(map[string]struct{})
for _, colName := range resultSet.Columns() {
seenColumns[colName] = struct{}{}
if r := item.Renderer(colName); r != nil {
s.renderItem(tabWriter, "", colName, r, styles)
}
}
for k, _ := range item {
if _, seen := seenColumns[k]; !seen {
if r := item.Renderer(k); r != nil {
s.renderItem(tabWriter, "", k, r, styles)
}
}
}
tabWriter.Flush()
}
func (m *Service) renderItem(w io.Writer, prefix string, name string, r itemrender.Renderer, sr styleRenderer) {
fmt.Fprintf(w, "%s%v\t%s\t%s%s\n",
prefix, name, sr.fileTypeRenderer.Render(r.TypeName()), r.StringValue(), sr.metaInfoRenderer.Render(r.MetaInfo()))
if subitems := r.SubItems(); len(subitems) > 0 {
for _, si := range subitems {
m.renderItem(w, prefix+" ", si.Key, si.Value, sr)
}
}
}
type styleRenderer struct {
fileTypeRenderer StyleRenderer
metaInfoRenderer StyleRenderer
}

View file

@ -0,0 +1,11 @@
package itemrenderer
type StyleRenderer interface {
Render(str string) string
}
type plainTextStyleRenderer struct{}
func (plainTextStyleRenderer) Render(str string) string {
return str
}

View file

@ -6,6 +6,7 @@ import (
"github.com/lmika/audax/internal/common/ui/events"
"github.com/lmika/audax/internal/dynamo-browse/controllers"
"github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dialogprompt"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemedit"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemview"
@ -31,11 +32,16 @@ type Model struct {
itemView *dynamoitemview.Model
}
func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteController, cc *commandctrl.CommandController) Model {
func NewModel(
rc *controllers.TableReadController,
wc *controllers.TableWriteController,
itemRendererService *itemrenderer.Service,
cc *commandctrl.CommandController,
) Model {
uiStyles := styles.DefaultStyles
dtv := dynamotableview.New(uiStyles)
div := dynamoitemview.New(uiStyles)
div := dynamoitemview.New(itemRendererService, uiStyles)
mainView := layout.NewVBox(layout.LastChildFixedAt(13), dtv, div)
itemEdit := dynamoitemedit.NewModel(mainView)
@ -143,6 +149,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, func() tea.Msg { return m.tableWriteController.ToggleMark(idx) }
}
case "c":
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, func() tea.Msg { return m.tableReadController.CopyItemToClipboard(idx) }
}
case "R":
return m, m.tableReadController.Rescan
case "?":

View file

@ -1,48 +1,34 @@
package dynamoitemview
import (
"fmt"
"github.com/lmika/audax/internal/dynamo-browse/models/itemrender"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/styles"
"io"
"strings"
"text/tabwriter"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout"
)
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
fieldTypeStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#2B800C", Dark: "#73C653"})
metaInfoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/styles"
"strings"
)
type Model struct {
ready bool
frameTitle frame.FrameTitle
viewport viewport.Model
w, h int
ready bool
frameTitle frame.FrameTitle
viewport viewport.Model
itemRendererService *itemrenderer.Service
w, h int
// model state
currentResultSet *models.ResultSet
selectedItem models.Item
}
func New(uiStyles styles.Styles) *Model {
func New(itemRendererService *itemrenderer.Service, uiStyles styles.Styles) *Model {
return &Model{
frameTitle: frame.NewFrameTitle("Item", false, uiStyles.Frames),
viewport: viewport.New(100, 100),
itemRendererService: itemRendererService,
frameTitle: frame.NewFrameTitle("Item", false, uiStyles.Frames),
viewport: viewport.New(100, 100),
}
}
@ -89,35 +75,8 @@ func (m *Model) updateViewportToSelectedMessage() {
}
viewportContent := &strings.Builder{}
tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0)
seenColumns := make(map[string]struct{})
for _, colName := range m.currentResultSet.Columns() {
seenColumns[colName] = struct{}{}
if r := m.selectedItem.Renderer(colName); r != nil {
m.renderItem(tabWriter, "", colName, r)
}
}
for k, _ := range m.selectedItem {
if _, seen := seenColumns[k]; !seen {
if r := m.selectedItem.Renderer(k); r != nil {
m.renderItem(tabWriter, "", k, r)
}
}
}
tabWriter.Flush()
m.itemRendererService.RenderItem(viewportContent, m.selectedItem, m.currentResultSet, false)
m.viewport.Width = m.w
m.viewport.Height = m.h - m.frameTitle.HeaderHeight()
m.viewport.SetContent(viewportContent.String())
}
func (m *Model) renderItem(w io.Writer, prefix string, name string, r itemrender.Renderer) {
fmt.Fprintf(w, "%s%v\t%s\t%s%s\n",
prefix, name, fieldTypeStyle.Render(r.TypeName()), r.StringValue(), metaInfoStyle.Render(r.MetaInfo()))
if subitems := r.SubItems(); len(subitems) > 0 {
for _, si := range subitems {
m.renderItem(w, prefix+" ", si.Key, si.Value)
}
}
}

View file

@ -7,11 +7,21 @@ import (
)
type Styles struct {
ItemView ItemViewStyle
Frames frame.Style
StatusAndPrompt statusandprompt.Style
}
type ItemViewStyle struct {
FieldType lipgloss.Style
MetaInfo lipgloss.Style
}
var DefaultStyles = Styles{
ItemView: ItemViewStyle{
FieldType: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#2B800C", Dark: "#73C653"}),
MetaInfo: lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")),
},
Frames: frame.Style{
ActiveTitle: lipgloss.NewStyle().
Bold(true).