Added the rel-picker which can quickly goto related tables

* New rel-picker that can be opened using Shift+O and allows for quickly going to related tables.
This commit is contained in:
Leon Mika 2024-03-03 09:20:28 +11:00 committed by GitHub
parent 12909c89ee
commit 5d95d44a97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 730 additions and 28 deletions

View file

@ -4,6 +4,10 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"log"
"net"
"os"
"github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -30,9 +34,6 @@ import (
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles"
bus "github.com/lmika/events" bus "github.com/lmika/events"
"github.com/lmika/gopkgs/cli" "github.com/lmika/gopkgs/cli"
"log"
"net"
"os"
) )
func main() { func main() {
@ -118,6 +119,7 @@ func main() {
inputHistoryService, inputHistoryService,
eventBus, eventBus,
pasteboardProvider, pasteboardProvider,
scriptManagerService,
*flagTable, *flagTable,
) )
tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore) tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore)
@ -125,7 +127,7 @@ func main() {
exportController := controllers.NewExportController(state, tableService, jobsController, columnsController, pasteboardProvider) exportController := controllers.NewExportController(state, tableService, jobsController, columnsController, pasteboardProvider)
settingsController := controllers.NewSettingsController(settingStore, eventBus) settingsController := controllers.NewSettingsController(settingStore, eventBus)
keyBindings := keybindings.Default() keyBindings := keybindings.Default()
scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, settingsController, eventBus) scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, jobsController, settingsController, eventBus)
if *flagQuery != "" { if *flagQuery != "" {
if *flagTable == "" { if *flagTable == "" {

View file

@ -2,10 +2,12 @@ package controllers
import ( import (
"fmt" "fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"strings" "strings"
"time" "time"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems"
) )
type SetTableItemView struct { type SetTableItemView struct {
@ -89,3 +91,9 @@ func (rs ResultSetUpdated) StatusMessage() string {
type ShowColumnOverlay struct{} type ShowColumnOverlay struct{}
type HideColumnOverlay struct{} type HideColumnOverlay struct{}
type ShowRelatedItemsOverlay struct {
Items []relitems.RelatedItem
OnSelected func(item relitems.RelatedItem) tea.Msg
}
type HideRelatedItemsOverlay struct{}

View file

@ -2,10 +2,12 @@ package controllers
import ( import (
"context" "context"
"io/fs"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models" "github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"io/fs" "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems"
) )
type TableReadService interface { type TableReadService interface {
@ -33,3 +35,7 @@ type CustomKeyBindingSource interface {
UnbindKey(key string) UnbindKey(key string)
Rebind(bindingName string, newKey string) error Rebind(bindingName string, newKey string) error
} }
type RelatedItemSupplier interface {
RelatedItemOfItem(context.Context, *models.ResultSet, int) ([]relitems.RelatedItem, error)
}

View file

@ -3,21 +3,24 @@ package controllers
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/common/ui/events" "github.com/lmika/dynamo-browse/internal/common/ui/events"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models" "github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
bus "github.com/lmika/events" bus "github.com/lmika/events"
"github.com/pkg/errors" "github.com/pkg/errors"
"log"
"strings"
) )
type ScriptController struct { type ScriptController struct {
scriptManager *scriptmanager.Service scriptManager *scriptmanager.Service
tableReadController *TableReadController tableReadController *TableReadController
jobController *JobsController
settingsController *SettingsController settingsController *SettingsController
eventBus *bus.Bus eventBus *bus.Bus
sendMsg func(msg tea.Msg) sendMsg func(msg tea.Msg)
@ -26,12 +29,14 @@ type ScriptController struct {
func NewScriptController( func NewScriptController(
scriptManager *scriptmanager.Service, scriptManager *scriptmanager.Service,
tableReadController *TableReadController, tableReadController *TableReadController,
jobController *JobsController,
settingsController *SettingsController, settingsController *SettingsController,
eventBus *bus.Bus, eventBus *bus.Bus,
) *ScriptController { ) *ScriptController {
sc := &ScriptController{ sc := &ScriptController{
scriptManager: scriptManager, scriptManager: scriptManager,
tableReadController: tableReadController, tableReadController: tableReadController,
jobController: jobController,
settingsController: settingsController, settingsController: settingsController,
eventBus: eventBus, eventBus: eventBus,
} }
@ -162,7 +167,6 @@ func (s *sessionImpl) SetResultSet(ctx context.Context, newResultSet *models.Res
} }
func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanager.QueryOptions) (*models.ResultSet, error) { func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanager.QueryOptions) (*models.ResultSet, error) {
// Parse the query // Parse the query
expr, err := queryexpr.Parse(query) expr, err := queryexpr.Parse(query)
if err != nil { if err != nil {
@ -179,11 +183,18 @@ func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanage
expr = expr.WithIndex(opts.IndexName) expr = expr.WithIndex(opts.IndexName)
} }
return s.sc.doQuery(ctx, expr, opts)
}
func (s *ScriptController) doQuery(ctx context.Context, expr *queryexpr.QueryExpr, opts scriptmanager.QueryOptions) (*models.ResultSet, error) {
// Get the table info // Get the table info
var tableInfo *models.TableInfo var (
tableInfo *models.TableInfo
err error
)
tableName := opts.TableName tableName := opts.TableName
currentResultSet := s.sc.tableReadController.state.ResultSet() currentResultSet := s.tableReadController.state.ResultSet()
if tableName != "" { if tableName != "" {
// Table specified. If it's the same as the current table, then use the existing table info // Table specified. If it's the same as the current table, then use the existing table info
@ -192,7 +203,7 @@ func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanage
} }
// Otherwise, describe the table // Otherwise, describe the table
tableInfo, err = s.sc.tableReadController.tableService.Describe(ctx, tableName) tableInfo, err = s.tableReadController.tableService.Describe(ctx, tableName)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "cannot describe table '%v'", tableName) return nil, errors.Wrapf(err, "cannot describe table '%v'", tableName)
} }
@ -204,7 +215,7 @@ func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanage
tableInfo = currentResultSet.TableInfo tableInfo = currentResultSet.TableInfo
} }
newResultSet, err := s.sc.tableReadController.tableService.ScanOrQuery(ctx, tableInfo, expr, nil) newResultSet, err := s.tableReadController.tableService.ScanOrQuery(ctx, tableInfo, expr, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -240,3 +251,31 @@ func (sc *ScriptController) LookupBinding(theKey string) string {
func (sc *ScriptController) UnbindKey(key string) { func (sc *ScriptController) UnbindKey(key string) {
sc.scriptManager.UnbindKey(key) sc.scriptManager.UnbindKey(key)
} }
func (c *ScriptController) LookupRelatedItems(idx int) (res tea.Msg) {
rs := c.tableReadController.state.ResultSet()
relItems, err := c.scriptManager.RelatedItemOfItem(context.Background(), rs, idx)
if err != nil {
return events.Error(err)
} else if len(relItems) == 0 {
return events.StatusMsg("No related items available")
}
return ShowRelatedItemsOverlay{
Items: relItems,
OnSelected: func(item relitems.RelatedItem) tea.Msg {
if item.OnSelect != nil {
return item.OnSelect()
}
return NewJob(c.jobController, "Running query…", func(ctx context.Context) (*models.ResultSet, error) {
return c.doQuery(ctx, item.Query, scriptmanager.QueryOptions{
TableName: item.Table,
})
}).OnDone(func(rs *models.ResultSet) tea.Msg {
return c.tableReadController.setResultSetAndFilter(rs, "", true, resultSetUpdateQuery)
}).Submit()
},
}
}

View file

@ -60,6 +60,7 @@ type TableReadController struct {
tableName string tableName string
loadFromLastView bool loadFromLastView bool
pasteboardProvider services.PasteboardProvider pasteboardProvider services.PasteboardProvider
relatedItemSupplier RelatedItemSupplier
// state // state
mutex *sync.Mutex mutex *sync.Mutex
@ -75,6 +76,7 @@ func NewTableReadController(
inputHistoryService *inputhistory.Service, inputHistoryService *inputhistory.Service,
eventBus *bus.Bus, eventBus *bus.Bus,
pasteboardProvider services.PasteboardProvider, pasteboardProvider services.PasteboardProvider,
relatedItemSupplier RelatedItemSupplier,
tableName string, tableName string,
) *TableReadController { ) *TableReadController {
return &TableReadController{ return &TableReadController{
@ -87,6 +89,7 @@ func NewTableReadController(
eventBus: eventBus, eventBus: eventBus,
tableName: tableName, tableName: tableName,
pasteboardProvider: pasteboardProvider, pasteboardProvider: pasteboardProvider,
relatedItemSupplier: relatedItemSupplier,
mutex: new(sync.Mutex), mutex: new(sync.Mutex),
} }
} }

View file

@ -627,13 +627,14 @@ func newService(t *testing.T, cfg serviceConfig) *services {
inputHistoryService, inputHistoryService,
eventBus, eventBus,
pasteboardprovider.NilProvider{}, pasteboardprovider.NilProvider{},
nil,
cfg.tableName, cfg.tableName,
) )
writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore)
settingsController := controllers.NewSettingsController(settingStore, eventBus) settingsController := controllers.NewSettingsController(settingStore, eventBus)
columnsController := controllers.NewColumnsController(eventBus) columnsController := controllers.NewColumnsController(eventBus)
exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{}) exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{})
scriptController := controllers.NewScriptController(scriptService, readController, settingsController, eventBus) scriptController := controllers.NewScriptController(scriptService, readController, jobsController, settingsController, eventBus)
commandController := commandctrl.NewCommandController(inputHistoryService) commandController := commandctrl.NewCommandController(inputHistoryService)
commandController.AddCommandLookupExtension(scriptController) commandController.AddCommandLookupExtension(scriptController)

View file

@ -0,0 +1,12 @@
package relitems
import (
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
)
type RelatedItem struct {
Name string
Table string
Query *queryexpr.QueryExpr
OnSelect func() error
}

View file

@ -8,8 +8,10 @@ package scriptmanager
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/risor-io/risor/object"
"log" "log"
"github.com/pkg/errors"
"github.com/risor-io/risor/object"
) )
func printBuiltin(ctx context.Context, args ...object.Object) object.Object { func printBuiltin(ctx context.Context, args ...object.Object) object.Object {
@ -70,3 +72,31 @@ func require(funcName string, count int, args []object.Object) *object.Error {
} }
return nil return nil
} }
func bindArgs(funcName string, args []object.Object, bindArgs ...any) *object.Error {
if err := require(funcName, len(bindArgs), args); err != nil {
return err
}
for i, bindArg := range bindArgs {
switch t := bindArg.(type) {
case *string:
str, err := object.AsString(args[i])
if err != nil {
return err
}
*t = str
case **object.Function:
fnRes, isFnRes := args[i].(*object.Function)
if !isFnRes {
return object.NewError(errors.Errorf("expected arg %v to be a function, was %T", i, bindArg))
}
*t = fnRes
default:
return object.NewError(errors.Errorf("unhandled arg type %v", i))
}
}
return nil
}

View file

@ -3,9 +3,13 @@ package scriptmanager
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/risor-io/risor/object" "github.com/risor-io/risor/object"
"regexp"
) )
var ( var (
@ -18,8 +22,9 @@ type extModule struct {
func (m *extModule) register() *object.Module { func (m *extModule) register() *object.Module {
return object.NewBuiltinsModule("ext", map[string]object.Object{ return object.NewBuiltinsModule("ext", map[string]object.Object{
"command": object.NewBuiltin("command", m.command), "command": object.NewBuiltin("command", m.command),
"key_binding": object.NewBuiltin("key_binding", m.keyBinding), "key_binding": object.NewBuiltin("key_binding", m.keyBinding),
"related_items": object.NewBuiltin("related_items", m.relatedItem),
}) })
} }
@ -136,3 +141,130 @@ func (m *extModule) keyBinding(ctx context.Context, args ...object.Object) objec
m.scriptPlugin.keyToKeyBinding[defaultKey] = fullBindingName m.scriptPlugin.keyToKeyBinding[defaultKey] = fullBindingName
return nil return nil
} }
func (m *extModule) relatedItem(ctx context.Context, args ...object.Object) object.Object {
thisEnv := scriptEnvFromCtx(ctx)
var (
tableName string
callbackFn *object.Function
)
if err := bindArgs("ext.related_items", args, &tableName, &callbackFn); err != nil {
return err
}
callFn, hasCallFn := object.GetCallFunc(ctx)
if !hasCallFn {
return object.NewError(errors.New("no callFn found in context"))
}
newHandler := func(ctx context.Context, rs *models.ResultSet, index int) ([]relatedItem, error) {
newEnv := thisEnv
ctx = ctxWithScriptEnv(ctx, newEnv)
res, err := callFn(ctx, callbackFn, []object.Object{
newItemProxy(newResultSetProxy(rs), index),
})
if err != nil {
return nil, errors.Errorf("script error '%v':related_item - %v", m.scriptPlugin.name, err)
} else if object.IsError(res) {
errObj := res.(*object.Error)
return nil, errors.Errorf("script error '%v':related_item - %v", m.scriptPlugin.name, errObj.Inspect())
}
itr, objErr := object.AsIterator(res)
if err != nil {
return nil, objErr.Value()
}
var relItems []relatedItem
for next, hasNext := itr.Next(ctx); hasNext; next, hasNext = itr.Next(ctx) {
var newRelItem relatedItem
itemMap, objErr := object.AsMap(next)
if err != nil {
return nil, objErr.Value()
}
labelName, objErr := object.AsString(itemMap.Get("label"))
if objErr != nil {
continue
}
newRelItem.label = labelName
var tableStr = ""
if itemMap.Get("table") != object.Nil {
tableStr, objErr = object.AsString(itemMap.Get("table"))
if objErr != nil {
continue
}
}
newRelItem.table = tableStr
if selectFn, ok := itemMap.Get("on_select").(*object.Function); ok {
newRelItem.onSelect = func() error {
thisNewEnv := thisEnv
ctx = ctxWithScriptEnv(ctx, thisNewEnv)
res, err := callFn(ctx, selectFn, []object.Object{})
if err != nil {
return errors.Errorf("rel error '%v' - %v", m.scriptPlugin.name, err)
} else if object.IsError(res) {
errObj := res.(*object.Error)
return errors.Errorf("rel error '%v' - %v", m.scriptPlugin.name, errObj.Inspect())
}
return nil
}
} else {
queryExprStr, objErr := object.AsString(itemMap.Get("query"))
if objErr != nil {
continue
}
query, err := queryexpr.Parse(queryExprStr)
if err != nil {
continue
}
// Placeholders
if argsVal, isArgsValMap := object.AsMap(itemMap.Get("args")); isArgsValMap == nil {
namePlaceholders := make(map[string]string)
valuePlaceholders := make(map[string]types.AttributeValue)
for k, val := range argsVal.Value() {
switch v := val.(type) {
case *object.String:
namePlaceholders[k] = v.Value()
valuePlaceholders[k] = &types.AttributeValueMemberS{Value: v.Value()}
case *object.Int:
valuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())}
case *object.Float:
valuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())}
case *object.Bool:
valuePlaceholders[k] = &types.AttributeValueMemberBOOL{Value: v.Value()}
case *object.NilType:
valuePlaceholders[k] = &types.AttributeValueMemberNULL{Value: true}
default:
continue
}
}
query = query.WithNameParams(namePlaceholders).WithValueParams(valuePlaceholders)
}
newRelItem.query = query
}
relItems = append(relItems, newRelItem)
}
return relItems, nil
}
m.scriptPlugin.relatedItems = append(m.scriptPlugin.relatedItems, &relatedItemBuilder{
table: tableName,
itemProduction: newHandler,
})
return nil
}

View file

@ -0,0 +1,151 @@
package scriptmanager_test
import (
"context"
"testing"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
"github.com/stretchr/testify/assert"
)
func TestExtModule_RelatedItems(t *testing.T) {
t.Run("should register a function which will return related items for an item", func(t *testing.T) {
scenarios := []struct {
desc string
code string
}{
{
desc: "single function, table name match",
code: `
ext.related_items("test-table", func(item) {
print("Hello")
return [
{"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}},
{"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}},
]
})
`,
},
{
desc: "single function, table prefix match",
code: `
ext.related_items("test-*", func(item) {
print("Hello")
return [
{"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}},
{"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}},
]
})
`,
},
{
desc: "multi function, table name match",
code: `
ext.related_items("test-table", func(item) {
print("Hello")
return [
{"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}},
]
})
ext.related_items("test-table", func(item) {
return [
{"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}},
]
})
`,
},
{
desc: "multi function, table name prefix",
code: `
ext.related_items("test-*", func(item) {
print("Hello")
return [
{"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}},
]
})
ext.related_items("test-*", func(item) {
return [
{"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}},
]
})
`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.desc, func(t *testing.T) {
// Load the script
srv := scriptmanager.New(scriptmanager.WithFS(testScriptFile(t, "test.tm", scenario.code)))
ctx := context.Background()
plugin, err := srv.LoadScript(ctx, "test.tm")
assert.NoError(t, err)
assert.NotNil(t, plugin)
// Get related items of result set
rs := &models.ResultSet{
TableInfo: &models.TableInfo{
Name: "test-table",
},
}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
relItems, err := srv.RelatedItemOfItem(context.Background(), rs, 0)
assert.NoError(t, err)
assert.Len(t, relItems, 2)
assert.Equal(t, "Customer", relItems[0].Name)
assert.Equal(t, "pk=$foo", relItems[0].Query.String())
assert.Equal(t, "foo", relItems[0].Query.ValueParamOrNil("foo").(*types.AttributeValueMemberS).Value)
assert.Equal(t, "Payment", relItems[1].Name)
assert.Equal(t, "fla=$daa", relItems[1].Query.String())
assert.Equal(t, "Hello", relItems[1].Query.ValueParamOrNil("daa").(*types.AttributeValueMemberS).Value)
})
}
})
t.Run("should support rel_items with on select", func(t *testing.T) {
// Load the script
srv := scriptmanager.New(scriptmanager.WithFS(testScriptFile(t, "test.tm", `
ext.related_items("test-table", func(item) {
print("Hello")
return [
{"label": "Customer", "on_select": func() {
print("Selected")
}},
]
})
`)))
ctx := context.Background()
plugin, err := srv.LoadScript(ctx, "test.tm")
assert.NoError(t, err)
assert.NotNil(t, plugin)
// Get related items of result set
rs := &models.ResultSet{
TableInfo: &models.TableInfo{
Name: "test-table",
},
}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
relItems, err := srv.RelatedItemOfItem(context.Background(), rs, 0)
assert.NoError(t, err)
assert.Len(t, relItems, 1)
assert.Equal(t, "Customer", relItems[0].Name)
assert.NoError(t, relItems[0].OnSelect())
})
}

View file

@ -0,0 +1,57 @@
package scriptmanager
import (
"context"
"log"
"path"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems"
)
type relatedItem struct {
label string
table string
query *queryexpr.QueryExpr
onSelect func() error
}
type relatedItemBuilder struct {
table string
itemProduction func(ctx context.Context, rs *models.ResultSet, index int) ([]relatedItem, error)
}
func (s *Service) RelatedItemOfItem(ctx context.Context, rs *models.ResultSet, index int) ([]relitems.RelatedItem, error) {
riModels := []relitems.RelatedItem{}
for _, plugin := range s.plugins {
for _, rb := range plugin.relatedItems {
// TODO: should support matching
match, _ := tableMatchesGlob(rb.table, rs.TableInfo.Name)
log.Printf("RelatedItemOfItem: table = '%v', pattern = '%v', match = '%v'", rb.table, rs.TableInfo.Name, match)
if match {
relatedItems, err := rb.itemProduction(ctx, rs, index)
if err != nil {
// TODO: should probably return error if no rel items were found and an error was raised
return nil, err
}
// TODO: make this nicer
for _, ri := range relatedItems {
riModels = append(riModels, relitems.RelatedItem{
Name: ri.label,
Query: ri.query,
Table: ri.table,
OnSelect: ri.onSelect,
})
}
}
}
}
return riModels, nil
}
func tableMatchesGlob(tableName, pattern string) (bool, error) {
return path.Match(tableName, pattern)
}

View file

@ -17,6 +17,10 @@ type resultSetProxy struct {
resultSet *models.ResultSet resultSet *models.ResultSet
} }
func newResultSetProxy(rs *models.ResultSet) *resultSetProxy {
return &resultSetProxy{resultSet: rs}
}
func (r *resultSetProxy) SetAttr(name string, value object.Object) error { func (r *resultSetProxy) SetAttr(name string, value object.Object) error {
return errors.Errorf("attribute error: %v", name) return errors.Errorf("attribute error: %v", name)
} }

View file

@ -14,6 +14,10 @@ import (
"github.com/risor-io/risor/object" "github.com/risor-io/risor/object"
) )
var (
relPrefix = "." + string(filepath.Separator)
)
type Service struct { type Service struct {
lookupPaths []fs.FS lookupPaths []fs.FS
ifaces Ifaces ifaces Ifaces
@ -150,7 +154,7 @@ func (s *Service) readScript(filename string, allowCwd bool) (string, error) {
} }
} }
if strings.HasPrefix(filename, string(filepath.Separator)) { if strings.HasPrefix(filename, string(filepath.Separator)) || strings.HasPrefix(filename, relPrefix) {
code, err := os.ReadFile(filename) code, err := os.ReadFile(filename)
if err != nil { if err != nil {
return "", err return "", err

View file

@ -1,6 +1,8 @@
package scriptmanager package scriptmanager
import "context" import (
"context"
)
type ScriptPlugin struct { type ScriptPlugin struct {
scriptService *Service scriptService *Service
@ -8,6 +10,7 @@ type ScriptPlugin struct {
definedCommands map[string]*Command definedCommands map[string]*Command
definedKeyBindings map[string]*Command definedKeyBindings map[string]*Command
keyToKeyBinding map[string]string keyToKeyBinding map[string]string
relatedItems []*relatedItemBuilder
} }
func (sp *ScriptPlugin) Name() string { func (sp *ScriptPlugin) Name() string {
@ -26,3 +29,7 @@ func (c *Command) Invoke(ctx context.Context, args []string, errChan chan error)
errChan <- c.cmdFn(ctx, args) errChan <- c.cmdFn(ctx, args)
}) })
} }
//func (c *Command) LookupRelevantItems(ctx context.Context, table *models.TableInfo, item *models.Item) error {
//
//}

View file

@ -37,6 +37,7 @@ func Default() *KeyBindings {
CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")), CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")),
PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")), PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")),
ShowColumnOverlay: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "show column overlay")), ShowColumnOverlay: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "show column overlay")),
ShowRelItemsOverlay: key.NewBinding(key.WithKeys("O"), key.WithHelp("O", "show related items overlay")),
CancelRunningJob: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "cancel running job or quit")), CancelRunningJob: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "cancel running job or quit")),
Quit: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")), Quit: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")),
}, },

View file

@ -45,6 +45,7 @@ type ViewKeyBindings struct {
CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"` CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"`
PromptForCommand key.Binding `keymap:"prompt-for-command"` PromptForCommand key.Binding `keymap:"prompt-for-command"`
ShowColumnOverlay key.Binding `keymap:"show-fields-popup"` ShowColumnOverlay key.Binding `keymap:"show-fields-popup"`
ShowRelItemsOverlay key.Binding `keymap:"show-rel-items-popup"`
CancelRunningJob key.Binding `keymap:"cancel-running-job"` CancelRunningJob key.Binding `keymap:"cancel-running-job"`
Quit key.Binding `keymap:"quit"` Quit key.Binding `keymap:"quit"`
} }

View file

@ -1,6 +1,10 @@
package ui package ui
import ( import (
"log"
"os"
"strings"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
@ -16,15 +20,13 @@ import (
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/dynamoitemview"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/dynamotableview" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/dynamotableview"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/relselector"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/statusandprompt" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/statusandprompt"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/tableselect" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/tableselect"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils"
bus "github.com/lmika/events" bus "github.com/lmika/events"
"github.com/pkg/errors" "github.com/pkg/errors"
"log"
"os"
"strings"
) )
const ( const (
@ -48,6 +50,7 @@ type Model struct {
scriptController *controllers.ScriptController scriptController *controllers.ScriptController
jobController *controllers.JobsController jobController *controllers.JobsController
colSelector *colselector.Model colSelector *colselector.Model
relSelector *relselector.Model
itemEdit *dynamoitemedit.Model itemEdit *dynamoitemedit.Model
statusAndPrompt *statusandprompt.StatusAndPrompt statusAndPrompt *statusandprompt.StatusAndPrompt
tableSelect *tableselect.Model tableSelect *tableselect.Model
@ -85,7 +88,8 @@ func NewModel(
mainView := layout.NewVBox(layout.LastChildFixedAt(14), dtv, div) mainView := layout.NewVBox(layout.LastChildFixedAt(14), dtv, div)
colSelector := colselector.New(mainView, defaultKeyMap, columnsController) colSelector := colselector.New(mainView, defaultKeyMap, columnsController)
itemEdit := dynamoitemedit.NewModel(colSelector) relSelector := relselector.New(colSelector)
itemEdit := dynamoitemedit.NewModel(relSelector)
statusAndPrompt := statusandprompt.New(itemEdit, pasteboardProvider, "", uiStyles.StatusAndPrompt) statusAndPrompt := statusandprompt.New(itemEdit, pasteboardProvider, "", uiStyles.StatusAndPrompt)
dialogPrompt := dialogprompt.New(statusAndPrompt) dialogPrompt := dialogprompt.New(statusAndPrompt)
tableSelect := tableselect.New(dialogPrompt, uiStyles) tableSelect := tableselect.New(dialogPrompt, uiStyles)
@ -244,6 +248,7 @@ func NewModel(
jobController: jobController, jobController: jobController,
itemEdit: itemEdit, itemEdit: itemEdit,
colSelector: colSelector, colSelector: colSelector,
relSelector: relSelector,
statusAndPrompt: statusAndPrompt, statusAndPrompt: statusAndPrompt,
tableSelect: tableSelect, tableSelect: tableSelect,
root: root, root: root,
@ -267,7 +272,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
) )
case tea.KeyMsg: case tea.KeyMsg:
// TODO: use modes here // TODO: use modes here
if !m.statusAndPrompt.InPrompt() && !m.tableSelect.Visible() && !m.colSelector.ColSelectorVisible() { if !m.statusAndPrompt.InPrompt() && !m.tableSelect.Visible() && !m.colSelector.ColSelectorVisible() && !m.relSelector.SelectorVisible() {
switch { switch {
case key.Matches(msg, m.keyMap.Mark): case key.Matches(msg, m.keyMap.Mark):
if idx := m.tableView.SelectedItemIndex(); idx >= 0 { if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
@ -302,6 +307,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// return m, nil // return m, nil
case key.Matches(msg, m.keyMap.ShowColumnOverlay): case key.Matches(msg, m.keyMap.ShowColumnOverlay):
return m, events.SetTeaMessage(controllers.ShowColumnOverlay{}) return m, events.SetTeaMessage(controllers.ShowColumnOverlay{})
case key.Matches(msg, m.keyMap.ShowRelItemsOverlay):
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, events.SetTeaMessage(m.scriptController.LookupRelatedItems(idx))
}
case key.Matches(msg, m.keyMap.PromptForCommand): case key.Matches(msg, m.keyMap.PromptForCommand):
return m, m.commandController.Prompt return m, m.commandController.Prompt
case key.Matches(msg, m.keyMap.PromptForTable): case key.Matches(msg, m.keyMap.PromptForTable):

View file

@ -10,7 +10,6 @@ import (
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout"
table "github.com/lmika/go-bubble-table" table "github.com/lmika/go-bubble-table"
"log"
"strings" "strings"
) )
@ -49,7 +48,6 @@ func (m *colListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case controllers.SetSelectedColumnInColSelector: case controllers.SetSelectedColumnInColSelector:
// HACK: this needs to work for all cases // HACK: this needs to work for all cases
log.Printf("%d == %d?", int(msg), m.table.Cursor()+1)
if int(msg) == m.table.Cursor()+1 { if int(msg) == m.table.Cursor()+1 {
m.table.GoDown() m.table.GoDown()
} }

View file

@ -0,0 +1,17 @@
package relselector
type relItemModel struct {
name string
}
func (ti relItemModel) FilterValue() string {
return ti.name
}
func (ti relItemModel) Title() string {
return ti.name
}
func (ti relItemModel) Description() string {
return ti.name
}

View file

@ -0,0 +1,132 @@
package relselector
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/dynamo-browse/internal/common/sliceutils"
"github.com/lmika/dynamo-browse/internal/common/ui/events"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils"
)
var (
frameColor = lipgloss.Color("63")
frameStyle = lipgloss.NewStyle().
Foreground(frameColor)
style = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(frameColor)
keyEsc = key.NewBinding(key.WithKeys(tea.KeyEsc.String()))
keyEnter = key.NewBinding(key.WithKeys(tea.KeyEnter.String()))
)
type listModel struct {
event controllers.ShowRelatedItemsOverlay
list list.Model
height int
}
func newListModel() *listModel {
items := []list.Item{}
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = false
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#2c5fb7")).
Foreground(lipgloss.Color("#2c5fb7")).
Padding(0, 0, 0, 1)
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#2c5fb7")).
Foreground(lipgloss.Color("#5277b7")).
Padding(0, 0, 0, 1)
list := list.New(items, delegate, overlayWidth, overlayHeight-4)
list.KeyMap.CursorUp = key.NewBinding(
key.WithKeys("up", "i"),
key.WithHelp("↑/i", "up"),
)
list.KeyMap.CursorDown = key.NewBinding(
key.WithKeys("down", "k"),
key.WithHelp("↓/k", "down"),
)
list.KeyMap.PrevPage = key.NewBinding(
key.WithKeys("left", "j", "pgup", "b", "u"),
key.WithHelp("←/j/pgup", "prev page"),
)
list.KeyMap.NextPage = key.NewBinding(
key.WithKeys("right", "l", "pgdown", "f", "d"),
key.WithHelp("→/l/pgdn", "next page"),
)
list.SetShowTitle(false)
list.SetShowHelp(false)
//list.DisableQuitKeybindings()
return &listModel{
list: list,
}
}
func (m *listModel) Init() tea.Cmd {
return nil
}
func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cc utils.CmdCollector
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, keyEnter):
if onSel := m.event.OnSelected; onSel != nil {
cc.Add(events.SetTeaMessage(onSel(m.event.Items[m.list.Index()])))
}
return m, events.SetTeaMessage(controllers.HideColumnOverlay{})
case key.Matches(msg, keyEsc):
return m, events.SetTeaMessage(controllers.HideColumnOverlay{})
default:
m.list = cc.Collect(m.list.Update(msg)).(list.Model)
}
default:
m.list = cc.Collect(m.list.Update(msg)).(list.Model)
}
return m, cc.Cmd()
}
func (m *listModel) View() string {
innerView := lipgloss.JoinVertical(
lipgloss.Top,
lipgloss.PlaceHorizontal(overlayWidth-2, lipgloss.Center, "Related Items"),
frameStyle.Render(strings.Repeat(lipgloss.NormalBorder().Top, overlayWidth-2)),
m.list.View(),
)
view := style.Width(overlayWidth - 2).Height(m.height - 2).Render(innerView)
return view
}
func (m *listModel) Resize(w, h int) layout.ResizingModel {
return m
}
func (m *listModel) setItems(event controllers.ShowRelatedItemsOverlay, newHeight int) {
listItems := sliceutils.Map(event.Items, func(item relitems.RelatedItem) list.Item {
return relItemModel{name: item.Name}
})
m.event = event
m.list.SetItems(listItems)
m.list.Select(0)
m.list.SetHeight(newHeight - 4)
m.height = newHeight
}

View file

@ -0,0 +1,73 @@
package relselector
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils"
)
const (
overlayWidth = 50
overlayHeight = 8
overlayHeightExtra2 = 2
maxItems = 8
)
type Model struct {
subModel tea.Model
compositor *layout.Compositor
listModel *listModel
w, h int
}
func New(subModel tea.Model) *Model {
compositor := layout.NewCompositor(subModel)
listModel := newListModel()
return &Model{
subModel: subModel,
listModel: listModel,
compositor: compositor,
}
}
func (m *Model) Init() tea.Cmd {
return m.compositor.Init()
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cc utils.CmdCollector
switch msg := msg.(type) {
case controllers.ShowRelatedItemsOverlay:
newHeight := overlayHeight + utils.Min(len(msg.Items), maxItems)*overlayHeightExtra2
m.listModel.setItems(msg, newHeight)
m.compositor.SetOverlay(m.listModel, m.w/2-overlayWidth/2, m.h/2-newHeight/2, overlayWidth, newHeight)
case controllers.HideColumnOverlay:
m.compositor.ClearOverlay()
case tea.KeyMsg:
m.compositor = cc.Collect(m.compositor.Update(msg)).(*layout.Compositor)
default:
m.subModel = cc.Collect(m.subModel.Update(msg)).(tea.Model)
}
return m, cc.Cmd()
}
func (m *Model) View() string {
return m.compositor.View()
}
func (m *Model) Resize(w, h int) layout.ResizingModel {
m.w, m.h = w, h
m.compositor.MoveOverlay(m.w/2-overlayWidth/2, m.h/2-overlayHeight/2)
m.listModel.Resize(w, h)
m.subModel = layout.Resize(m.subModel, w, h)
m.listModel = layout.Resize(m.listModel, w, h).(*listModel)
return m
}
func (m *Model) SelectorVisible() bool {
return m.compositor.HasOverlay()
}

View file

@ -1,5 +1,12 @@
package utils package utils
func Min(x, y int) int {
if x < y {
return x
}
return y
}
func Max(x, y int) int { func Max(x, y int) int {
if x > y { if x > y {
return x return x

8
test.tm Normal file
View file

@ -0,0 +1,8 @@
ext.related_items("business-addresses", func(item) {
print("Hello")
return [
{"label": "Customer", "query": `city="Austin"`, "args": {"foo": "foo"}},
{"label": "Payment", "query": `officeOpened=false`, "args": {"daa": "Hello"}},
{"label": "Thing", "query": `colors.door^="P"`, "args": {"daa": "Hello"}},
]
})