From 5d95d44a9719912fb7be89569375af16f7652045 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 3 Mar 2024 09:20:28 +1100 Subject: [PATCH] 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. --- cmd/dynamo-browse/main.go | 10 +- internal/dynamo-browse/controllers/events.go | 12 +- internal/dynamo-browse/controllers/iface.go | 8 +- internal/dynamo-browse/controllers/scripts.go | 53 +++++- .../dynamo-browse/controllers/tableread.go | 3 + .../controllers/tablewrite_test.go | 3 +- .../dynamo-browse/models/relitems/relitem.go | 12 ++ .../services/scriptmanager/builtins.go | 32 +++- .../services/scriptmanager/modext.go | 138 +++++++++++++++- .../services/scriptmanager/modext_test.go | 151 ++++++++++++++++++ .../services/scriptmanager/relitem.go | 57 +++++++ .../services/scriptmanager/resultsetproxy.go | 4 + .../services/scriptmanager/service.go | 6 +- .../services/scriptmanager/types.go | 9 +- .../dynamo-browse/ui/keybindings/defaults.go | 1 + .../ui/keybindings/keybindings.go | 1 + internal/dynamo-browse/ui/model.go | 19 ++- .../ui/teamodels/colselector/colmodel.go | 2 - .../ui/teamodels/relselector/itemmdl.go | 17 ++ .../ui/teamodels/relselector/listmdl.go | 132 +++++++++++++++ .../ui/teamodels/relselector/model.go | 73 +++++++++ .../ui/teamodels/utils/minmax.go | 7 + test.tm | 8 + 23 files changed, 730 insertions(+), 28 deletions(-) create mode 100644 internal/dynamo-browse/models/relitems/relitem.go create mode 100644 internal/dynamo-browse/services/scriptmanager/modext_test.go create mode 100644 internal/dynamo-browse/services/scriptmanager/relitem.go create mode 100644 internal/dynamo-browse/ui/teamodels/relselector/itemmdl.go create mode 100644 internal/dynamo-browse/ui/teamodels/relselector/listmdl.go create mode 100644 internal/dynamo-browse/ui/teamodels/relselector/model.go create mode 100644 test.tm diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index c059b3a..ccaf034 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -4,6 +4,10 @@ import ( "context" "flag" "fmt" + "log" + "net" + "os" + "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb" tea "github.com/charmbracelet/bubbletea" @@ -30,9 +34,6 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles" bus "github.com/lmika/events" "github.com/lmika/gopkgs/cli" - "log" - "net" - "os" ) func main() { @@ -118,6 +119,7 @@ func main() { inputHistoryService, eventBus, pasteboardProvider, + scriptManagerService, *flagTable, ) tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore) @@ -125,7 +127,7 @@ func main() { exportController := controllers.NewExportController(state, tableService, jobsController, columnsController, pasteboardProvider) settingsController := controllers.NewSettingsController(settingStore, eventBus) keyBindings := keybindings.Default() - scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, settingsController, eventBus) + scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, jobsController, settingsController, eventBus) if *flagQuery != "" { if *flagTable == "" { diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index 3d0821f..7af1c41 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -2,10 +2,12 @@ package controllers import ( "fmt" - tea "github.com/charmbracelet/bubbletea" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" "strings" "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 { @@ -89,3 +91,9 @@ func (rs ResultSetUpdated) StatusMessage() string { type ShowColumnOverlay struct{} type HideColumnOverlay struct{} + +type ShowRelatedItemsOverlay struct { + Items []relitems.RelatedItem + OnSelected func(item relitems.RelatedItem) tea.Msg +} +type HideRelatedItemsOverlay struct{} diff --git a/internal/dynamo-browse/controllers/iface.go b/internal/dynamo-browse/controllers/iface.go index 101d190..7fa5239 100644 --- a/internal/dynamo-browse/controllers/iface.go +++ b/internal/dynamo-browse/controllers/iface.go @@ -2,10 +2,12 @@ package controllers import ( "context" + "io/fs" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" - "io/fs" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems" ) type TableReadService interface { @@ -33,3 +35,7 @@ type CustomKeyBindingSource interface { UnbindKey(key string) Rebind(bindingName string, newKey string) error } + +type RelatedItemSupplier interface { + RelatedItemOfItem(context.Context, *models.ResultSet, int) ([]relitems.RelatedItem, error) +} diff --git a/internal/dynamo-browse/controllers/scripts.go b/internal/dynamo-browse/controllers/scripts.go index ccbf941..f706ddd 100644 --- a/internal/dynamo-browse/controllers/scripts.go +++ b/internal/dynamo-browse/controllers/scripts.go @@ -3,21 +3,24 @@ package controllers import ( "context" "fmt" + "log" + "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" "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/queryexpr" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" bus "github.com/lmika/events" "github.com/pkg/errors" - "log" - "strings" ) type ScriptController struct { scriptManager *scriptmanager.Service tableReadController *TableReadController + jobController *JobsController settingsController *SettingsController eventBus *bus.Bus sendMsg func(msg tea.Msg) @@ -26,12 +29,14 @@ type ScriptController struct { func NewScriptController( scriptManager *scriptmanager.Service, tableReadController *TableReadController, + jobController *JobsController, settingsController *SettingsController, eventBus *bus.Bus, ) *ScriptController { sc := &ScriptController{ scriptManager: scriptManager, tableReadController: tableReadController, + jobController: jobController, settingsController: settingsController, 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) { - // Parse the query expr, err := queryexpr.Parse(query) if err != nil { @@ -179,11 +183,18 @@ func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanage 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 - var tableInfo *models.TableInfo + var ( + tableInfo *models.TableInfo + err error + ) tableName := opts.TableName - currentResultSet := s.sc.tableReadController.state.ResultSet() + currentResultSet := s.tableReadController.state.ResultSet() if tableName != "" { // 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 - tableInfo, err = s.sc.tableReadController.tableService.Describe(ctx, tableName) + tableInfo, err = s.tableReadController.tableService.Describe(ctx, tableName) if err != nil { 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 } - newResultSet, err := s.sc.tableReadController.tableService.ScanOrQuery(ctx, tableInfo, expr, nil) + newResultSet, err := s.tableReadController.tableService.ScanOrQuery(ctx, tableInfo, expr, nil) if err != nil { return nil, err } @@ -240,3 +251,31 @@ func (sc *ScriptController) LookupBinding(theKey string) string { func (sc *ScriptController) UnbindKey(key string) { 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() + }, + } +} diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index f055e7d..bfc650d 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -60,6 +60,7 @@ type TableReadController struct { tableName string loadFromLastView bool pasteboardProvider services.PasteboardProvider + relatedItemSupplier RelatedItemSupplier // state mutex *sync.Mutex @@ -75,6 +76,7 @@ func NewTableReadController( inputHistoryService *inputhistory.Service, eventBus *bus.Bus, pasteboardProvider services.PasteboardProvider, + relatedItemSupplier RelatedItemSupplier, tableName string, ) *TableReadController { return &TableReadController{ @@ -87,6 +89,7 @@ func NewTableReadController( eventBus: eventBus, tableName: tableName, pasteboardProvider: pasteboardProvider, + relatedItemSupplier: relatedItemSupplier, mutex: new(sync.Mutex), } } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 493b5c7..ac2d32a 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -627,13 +627,14 @@ func newService(t *testing.T, cfg serviceConfig) *services { inputHistoryService, eventBus, pasteboardprovider.NilProvider{}, + nil, cfg.tableName, ) writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) settingsController := controllers.NewSettingsController(settingStore, eventBus) columnsController := controllers.NewColumnsController(eventBus) 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.AddCommandLookupExtension(scriptController) diff --git a/internal/dynamo-browse/models/relitems/relitem.go b/internal/dynamo-browse/models/relitems/relitem.go new file mode 100644 index 0000000..3a46733 --- /dev/null +++ b/internal/dynamo-browse/models/relitems/relitem.go @@ -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 +} diff --git a/internal/dynamo-browse/services/scriptmanager/builtins.go b/internal/dynamo-browse/services/scriptmanager/builtins.go index 87e2852..93c6e78 100644 --- a/internal/dynamo-browse/services/scriptmanager/builtins.go +++ b/internal/dynamo-browse/services/scriptmanager/builtins.go @@ -8,8 +8,10 @@ package scriptmanager import ( "context" "fmt" - "github.com/risor-io/risor/object" "log" + + "github.com/pkg/errors" + "github.com/risor-io/risor/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 } + +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 +} diff --git a/internal/dynamo-browse/services/scriptmanager/modext.go b/internal/dynamo-browse/services/scriptmanager/modext.go index 0c858cf..4ad97d4 100644 --- a/internal/dynamo-browse/services/scriptmanager/modext.go +++ b/internal/dynamo-browse/services/scriptmanager/modext.go @@ -3,9 +3,13 @@ package scriptmanager import ( "context" "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/risor-io/risor/object" - "regexp" ) var ( @@ -18,8 +22,9 @@ type extModule struct { func (m *extModule) register() *object.Module { return object.NewBuiltinsModule("ext", map[string]object.Object{ - "command": object.NewBuiltin("command", m.command), - "key_binding": object.NewBuiltin("key_binding", m.keyBinding), + "command": object.NewBuiltin("command", m.command), + "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 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 +} diff --git a/internal/dynamo-browse/services/scriptmanager/modext_test.go b/internal/dynamo-browse/services/scriptmanager/modext_test.go new file mode 100644 index 0000000..b3413e6 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modext_test.go @@ -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()) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/relitem.go b/internal/dynamo-browse/services/scriptmanager/relitem.go new file mode 100644 index 0000000..63d7629 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/relitem.go @@ -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) +} diff --git a/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go index 0d6be7d..953a3e4 100644 --- a/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go +++ b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go @@ -17,6 +17,10 @@ type resultSetProxy struct { resultSet *models.ResultSet } +func newResultSetProxy(rs *models.ResultSet) *resultSetProxy { + return &resultSetProxy{resultSet: rs} +} + func (r *resultSetProxy) SetAttr(name string, value object.Object) error { return errors.Errorf("attribute error: %v", name) } diff --git a/internal/dynamo-browse/services/scriptmanager/service.go b/internal/dynamo-browse/services/scriptmanager/service.go index 8e17ae0..eac6638 100644 --- a/internal/dynamo-browse/services/scriptmanager/service.go +++ b/internal/dynamo-browse/services/scriptmanager/service.go @@ -14,6 +14,10 @@ import ( "github.com/risor-io/risor/object" ) +var ( + relPrefix = "." + string(filepath.Separator) +) + type Service struct { lookupPaths []fs.FS 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) if err != nil { return "", err diff --git a/internal/dynamo-browse/services/scriptmanager/types.go b/internal/dynamo-browse/services/scriptmanager/types.go index b0bf1a0..442f03b 100644 --- a/internal/dynamo-browse/services/scriptmanager/types.go +++ b/internal/dynamo-browse/services/scriptmanager/types.go @@ -1,6 +1,8 @@ package scriptmanager -import "context" +import ( + "context" +) type ScriptPlugin struct { scriptService *Service @@ -8,6 +10,7 @@ type ScriptPlugin struct { definedCommands map[string]*Command definedKeyBindings map[string]*Command keyToKeyBinding map[string]string + relatedItems []*relatedItemBuilder } 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) }) } + +//func (c *Command) LookupRelevantItems(ctx context.Context, table *models.TableInfo, item *models.Item) error { +// +//} diff --git a/internal/dynamo-browse/ui/keybindings/defaults.go b/internal/dynamo-browse/ui/keybindings/defaults.go index 87e2757..08a80e7 100644 --- a/internal/dynamo-browse/ui/keybindings/defaults.go +++ b/internal/dynamo-browse/ui/keybindings/defaults.go @@ -37,6 +37,7 @@ func Default() *KeyBindings { CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")), PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")), 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")), Quit: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")), }, diff --git a/internal/dynamo-browse/ui/keybindings/keybindings.go b/internal/dynamo-browse/ui/keybindings/keybindings.go index d8257de..d0c7fc3 100644 --- a/internal/dynamo-browse/ui/keybindings/keybindings.go +++ b/internal/dynamo-browse/ui/keybindings/keybindings.go @@ -45,6 +45,7 @@ type ViewKeyBindings struct { CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"` PromptForCommand key.Binding `keymap:"prompt-for-command"` ShowColumnOverlay key.Binding `keymap:"show-fields-popup"` + ShowRelItemsOverlay key.Binding `keymap:"show-rel-items-popup"` CancelRunningJob key.Binding `keymap:"cancel-running-job"` Quit key.Binding `keymap:"quit"` } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index c41b572..cb07519 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -1,6 +1,10 @@ package ui import ( + "log" + "os" + "strings" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "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/dynamotableview" "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/styles" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/tableselect" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils" bus "github.com/lmika/events" "github.com/pkg/errors" - "log" - "os" - "strings" ) const ( @@ -48,6 +50,7 @@ type Model struct { scriptController *controllers.ScriptController jobController *controllers.JobsController colSelector *colselector.Model + relSelector *relselector.Model itemEdit *dynamoitemedit.Model statusAndPrompt *statusandprompt.StatusAndPrompt tableSelect *tableselect.Model @@ -85,7 +88,8 @@ func NewModel( mainView := layout.NewVBox(layout.LastChildFixedAt(14), dtv, div) 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) dialogPrompt := dialogprompt.New(statusAndPrompt) tableSelect := tableselect.New(dialogPrompt, uiStyles) @@ -244,6 +248,7 @@ func NewModel( jobController: jobController, itemEdit: itemEdit, colSelector: colSelector, + relSelector: relSelector, statusAndPrompt: statusAndPrompt, tableSelect: tableSelect, root: root, @@ -267,7 +272,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ) case tea.KeyMsg: // 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 { case key.Matches(msg, m.keyMap.Mark): 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 case key.Matches(msg, m.keyMap.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): return m, m.commandController.Prompt case key.Matches(msg, m.keyMap.PromptForTable): diff --git a/internal/dynamo-browse/ui/teamodels/colselector/colmodel.go b/internal/dynamo-browse/ui/teamodels/colselector/colmodel.go index a261032..f6221b0 100644 --- a/internal/dynamo-browse/ui/teamodels/colselector/colmodel.go +++ b/internal/dynamo-browse/ui/teamodels/colselector/colmodel.go @@ -10,7 +10,6 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout" table "github.com/lmika/go-bubble-table" - "log" "strings" ) @@ -49,7 +48,6 @@ func (m *colListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case controllers.SetSelectedColumnInColSelector: // 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 { m.table.GoDown() } diff --git a/internal/dynamo-browse/ui/teamodels/relselector/itemmdl.go b/internal/dynamo-browse/ui/teamodels/relselector/itemmdl.go new file mode 100644 index 0000000..79372c7 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/relselector/itemmdl.go @@ -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 +} diff --git a/internal/dynamo-browse/ui/teamodels/relselector/listmdl.go b/internal/dynamo-browse/ui/teamodels/relselector/listmdl.go new file mode 100644 index 0000000..3bd601f --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/relselector/listmdl.go @@ -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 +} diff --git a/internal/dynamo-browse/ui/teamodels/relselector/model.go b/internal/dynamo-browse/ui/teamodels/relselector/model.go new file mode 100644 index 0000000..45658ec --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/relselector/model.go @@ -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() +} diff --git a/internal/dynamo-browse/ui/teamodels/utils/minmax.go b/internal/dynamo-browse/ui/teamodels/utils/minmax.go index 2fbb42f..17ffd5e 100644 --- a/internal/dynamo-browse/ui/teamodels/utils/minmax.go +++ b/internal/dynamo-browse/ui/teamodels/utils/minmax.go @@ -1,5 +1,12 @@ package utils +func Min(x, y int) int { + if x < y { + return x + } + return y +} + func Max(x, y int) int { if x > y { return x diff --git a/test.tm b/test.tm new file mode 100644 index 0000000..1fa251d --- /dev/null +++ b/test.tm @@ -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"}}, + ] +}) \ No newline at end of file