- Fixed a bug that was pushing duplicate view entries to the backstack - The appended column will now be selected once added
391 lines
12 KiB
Go
391 lines
12 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"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/models/serialisable"
|
|
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer"
|
|
"github.com/lmika/audax/internal/dynamo-browse/services/viewsnapshot"
|
|
bus "github.com/lmika/events"
|
|
"github.com/pkg/errors"
|
|
"golang.design/x/clipboard"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
type resultSetUpdateOp int
|
|
|
|
const (
|
|
resultSetUpdateInit resultSetUpdateOp = iota
|
|
resultSetUpdateQuery
|
|
resultSetUpdateFilter
|
|
resultSetUpdateSnapshotRestore
|
|
resultSetUpdateRescan
|
|
resultSetUpdateTouch
|
|
)
|
|
|
|
type MarkOp int
|
|
|
|
const (
|
|
MarkOpMark MarkOp = iota
|
|
MarkOpUnmark
|
|
MarkOpToggle
|
|
)
|
|
|
|
type TableReadController struct {
|
|
tableService TableReadService
|
|
workspaceService *viewsnapshot.ViewSnapshotService
|
|
itemRendererService *itemrenderer.Service
|
|
jobController *JobsController
|
|
eventBus *bus.Bus
|
|
tableName string
|
|
loadFromLastView bool
|
|
|
|
// state
|
|
mutex *sync.Mutex
|
|
state *State
|
|
clipboardInit bool
|
|
}
|
|
|
|
func NewTableReadController(
|
|
state *State,
|
|
tableService TableReadService,
|
|
workspaceService *viewsnapshot.ViewSnapshotService,
|
|
itemRendererService *itemrenderer.Service,
|
|
jobController *JobsController,
|
|
eventBus *bus.Bus,
|
|
tableName string,
|
|
) *TableReadController {
|
|
return &TableReadController{
|
|
state: state,
|
|
tableService: tableService,
|
|
workspaceService: workspaceService,
|
|
itemRendererService: itemRendererService,
|
|
jobController: jobController,
|
|
eventBus: eventBus,
|
|
tableName: tableName,
|
|
mutex: new(sync.Mutex),
|
|
}
|
|
}
|
|
|
|
// Init does an initial scan of the table. If no table is specified, it prompts for a table, then does a scan.
|
|
func (c *TableReadController) Init() tea.Msg {
|
|
// Restore previous view
|
|
if c.loadFromLastView {
|
|
if vs, err := c.workspaceService.ViewRestore(); err == nil && vs != nil {
|
|
return c.updateViewToSnapshot(vs)
|
|
}
|
|
}
|
|
|
|
if c.tableName == "" {
|
|
return c.ListTables()
|
|
} else {
|
|
return c.ScanTable(c.tableName)
|
|
}
|
|
}
|
|
|
|
func (c *TableReadController) ListTables() tea.Msg {
|
|
return NewJob(c.jobController, "Listing tables…", func(ctx context.Context) (any, error) {
|
|
tables, err := c.tableService.ListTables(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return tables, nil
|
|
}).OnDone(func(res any) tea.Msg {
|
|
return PromptForTableMsg{
|
|
Tables: res.([]string),
|
|
OnSelected: func(tableName string) tea.Msg {
|
|
if tableName == "" {
|
|
return events.StatusMsg("No table selected")
|
|
}
|
|
|
|
return c.ScanTable(tableName)
|
|
},
|
|
}
|
|
}).Submit()
|
|
}
|
|
|
|
func (c *TableReadController) ScanTable(name string) tea.Msg {
|
|
return NewJob(c.jobController, "Scanning…", func(ctx context.Context) (*models.ResultSet, error) {
|
|
tableInfo, err := c.tableService.Describe(ctx, name)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "cannot describe %v", c.tableName)
|
|
}
|
|
|
|
resultSet, err := c.tableService.Scan(ctx, tableInfo)
|
|
if resultSet != nil {
|
|
resultSet = c.tableService.Filter(resultSet, c.state.Filter())
|
|
}
|
|
|
|
return resultSet, err
|
|
}).OnEither(c.handleResultSetFromJobResult(c.state.Filter(), true, resultSetUpdateInit)).Submit()
|
|
}
|
|
|
|
func (c *TableReadController) PromptForQuery() tea.Msg {
|
|
return events.PromptForInputMsg{
|
|
Prompt: "query: ",
|
|
OnDone: func(value string) tea.Msg {
|
|
resultSet := c.state.ResultSet()
|
|
if resultSet == nil {
|
|
return events.StatusMsg("Result-set is nil")
|
|
}
|
|
|
|
return c.runQuery(resultSet.TableInfo, value, "", true)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFilter string, pushSnapshot bool) tea.Msg {
|
|
if query == "" {
|
|
return NewJob(c.jobController, "Scanning…", func(ctx context.Context) (*models.ResultSet, error) {
|
|
newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, nil)
|
|
|
|
if newResultSet != nil && newFilter != "" {
|
|
newResultSet = c.tableService.Filter(newResultSet, newFilter)
|
|
}
|
|
|
|
return newResultSet, err
|
|
}).OnEither(c.handleResultSetFromJobResult(newFilter, pushSnapshot, resultSetUpdateQuery)).Submit()
|
|
}
|
|
|
|
expr, err := queryexpr.Parse(query)
|
|
if err != nil {
|
|
return events.Error(err)
|
|
}
|
|
|
|
return c.doIfNoneDirty(func() tea.Msg {
|
|
return NewJob(c.jobController, "Running query…", func(ctx context.Context) (*models.ResultSet, error) {
|
|
newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, expr)
|
|
|
|
if newFilter != "" && newResultSet != nil {
|
|
newResultSet = c.tableService.Filter(newResultSet, newFilter)
|
|
}
|
|
return newResultSet, err
|
|
}).OnEither(c.handleResultSetFromJobResult(newFilter, pushSnapshot, resultSetUpdateQuery)).Submit()
|
|
})
|
|
}
|
|
|
|
func (c *TableReadController) doIfNoneDirty(cmd tea.Cmd) tea.Msg {
|
|
var anyDirty = false
|
|
for i := 0; i < len(c.state.ResultSet().Items()); i++ {
|
|
anyDirty = anyDirty || c.state.ResultSet().IsDirty(i)
|
|
}
|
|
|
|
if !anyDirty {
|
|
return cmd()
|
|
}
|
|
|
|
return events.PromptForInputMsg{
|
|
Prompt: "reset modified items? ",
|
|
OnDone: func(value string) tea.Msg {
|
|
if value != "y" {
|
|
return events.StatusMsg("operation aborted")
|
|
}
|
|
|
|
return cmd()
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *TableReadController) Rescan() tea.Msg {
|
|
return c.doIfNoneDirty(func() tea.Msg {
|
|
resultSet := c.state.ResultSet()
|
|
return c.doScan(resultSet, resultSet.Query, true, resultSetUpdateRescan)
|
|
})
|
|
}
|
|
|
|
func (c *TableReadController) doScan(resultSet *models.ResultSet, query models.Queryable, pushBackstack bool, op resultSetUpdateOp) tea.Msg {
|
|
return NewJob(c.jobController, "Rescan…", func(ctx context.Context) (*models.ResultSet, error) {
|
|
newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query)
|
|
if newResultSet != nil {
|
|
newResultSet = c.tableService.Filter(newResultSet, c.state.Filter())
|
|
}
|
|
|
|
return newResultSet, err
|
|
}).OnEither(c.handleResultSetFromJobResult(c.state.Filter(), pushBackstack, op)).Submit()
|
|
}
|
|
|
|
func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool, op resultSetUpdateOp) tea.Msg {
|
|
if resultSet != nil && pushBackstack {
|
|
details := serialisable.ViewSnapshotDetails{
|
|
TableName: resultSet.TableInfo.Name,
|
|
Filter: filter,
|
|
}
|
|
if q := resultSet.Query; q != nil {
|
|
details.Query = q.String()
|
|
}
|
|
|
|
if err := c.workspaceService.PushSnapshot(details); err != nil {
|
|
log.Printf("cannot push snapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
c.state.setResultSetAndFilter(resultSet, filter)
|
|
|
|
c.eventBus.Fire(newResultSetEvent, resultSet, op)
|
|
|
|
return c.state.buildNewResultSetMessage("")
|
|
}
|
|
|
|
func (c *TableReadController) Mark(op MarkOp) tea.Msg {
|
|
c.state.withResultSet(func(resultSet *models.ResultSet) {
|
|
for i := range resultSet.Items() {
|
|
if resultSet.Hidden(i) {
|
|
continue
|
|
}
|
|
|
|
switch op {
|
|
case MarkOpMark:
|
|
resultSet.SetMark(i, true)
|
|
case MarkOpUnmark:
|
|
resultSet.SetMark(i, false)
|
|
case MarkOpToggle:
|
|
resultSet.SetMark(i, !resultSet.Marked(i))
|
|
}
|
|
}
|
|
})
|
|
return ResultSetUpdated{}
|
|
}
|
|
|
|
func (c *TableReadController) Filter() tea.Msg {
|
|
return events.PromptForInputMsg{
|
|
Prompt: "filter: ",
|
|
OnDone: func(value string) tea.Msg {
|
|
resultSet := c.state.ResultSet()
|
|
if resultSet == nil {
|
|
return events.StatusMsg("Result-set is nil")
|
|
}
|
|
|
|
return NewJob(c.jobController, "Applying Filter…", func(ctx context.Context) (*models.ResultSet, error) {
|
|
newResultSet := c.tableService.Filter(resultSet, value)
|
|
return newResultSet, nil
|
|
}).OnEither(c.handleResultSetFromJobResult(value, true, resultSetUpdateFilter)).Submit()
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *TableReadController) handleResultSetFromJobResult(filter string, pushbackStack bool, op resultSetUpdateOp) func(newResultSet *models.ResultSet, err error) tea.Msg {
|
|
return func(newResultSet *models.ResultSet, err error) tea.Msg {
|
|
if err == nil {
|
|
return c.setResultSetAndFilter(newResultSet, filter, pushbackStack, op)
|
|
}
|
|
|
|
var partialResultsErr models.PartialResultsError
|
|
if errors.As(err, &partialResultsErr) {
|
|
return events.Confirm(applyToN("View the ", len(newResultSet.Items()), "item", "items", " returned so far? "), func(yes bool) tea.Msg {
|
|
if yes {
|
|
return c.setResultSetAndFilter(newResultSet, filter, pushbackStack, op)
|
|
}
|
|
return events.StatusMsg("Operation cancelled")
|
|
})
|
|
}
|
|
return events.Error(err)
|
|
}
|
|
}
|
|
|
|
func (c *TableReadController) ViewBack() tea.Msg {
|
|
viewSnapshot, err := c.workspaceService.ViewBack()
|
|
if err != nil {
|
|
return events.Error(err)
|
|
} else if viewSnapshot == nil {
|
|
return events.StatusMsg("Backstack is empty")
|
|
}
|
|
|
|
return c.updateViewToSnapshot(viewSnapshot)
|
|
}
|
|
|
|
func (c *TableReadController) ViewForward() tea.Msg {
|
|
viewSnapshot, err := c.workspaceService.ViewForward()
|
|
if err != nil {
|
|
return events.Error(err)
|
|
} else if viewSnapshot == nil {
|
|
return events.StatusMsg("At top of view stack")
|
|
}
|
|
|
|
return c.updateViewToSnapshot(viewSnapshot)
|
|
}
|
|
|
|
func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.ViewSnapshot) tea.Msg {
|
|
var err error
|
|
currentResultSet := c.state.ResultSet()
|
|
|
|
if currentResultSet == nil {
|
|
return NewJob(c.jobController, "Fetching table info…", func(ctx context.Context) (*models.TableInfo, error) {
|
|
tableInfo, err := c.tableService.Describe(context.Background(), viewSnapshot.Details.TableName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return tableInfo, nil
|
|
}).OnDone(func(tableInfo *models.TableInfo) tea.Msg {
|
|
return c.runQuery(tableInfo, viewSnapshot.Details.Query, viewSnapshot.Details.Filter, false)
|
|
}).Submit()
|
|
}
|
|
|
|
var currentQueryExpr string
|
|
if currentResultSet.Query != nil {
|
|
currentQueryExpr = currentResultSet.Query.String()
|
|
}
|
|
|
|
if viewSnapshot.Details.TableName == currentResultSet.TableInfo.Name && viewSnapshot.Details.Query == currentQueryExpr {
|
|
return NewJob(c.jobController, "Applying filter…", func(ctx context.Context) (*models.ResultSet, error) {
|
|
return c.tableService.Filter(currentResultSet, viewSnapshot.Details.Filter), nil
|
|
}).OnEither(c.handleResultSetFromJobResult(viewSnapshot.Details.Filter, false, resultSetUpdateSnapshotRestore)).Submit()
|
|
}
|
|
|
|
return NewJob(c.jobController, "Running query…", func(ctx context.Context) (tea.Msg, error) {
|
|
tableInfo := currentResultSet.TableInfo
|
|
if viewSnapshot.Details.TableName != currentResultSet.TableInfo.Name {
|
|
tableInfo, err = c.tableService.Describe(context.Background(), viewSnapshot.Details.TableName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return c.runQuery(tableInfo, viewSnapshot.Details.Query, viewSnapshot.Details.Filter, false), nil
|
|
}).OnDone(func(m tea.Msg) tea.Msg {
|
|
return m
|
|
}).Submit()
|
|
}
|
|
|
|
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.StatusMsg(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
|
|
}
|