diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 495d3ae..ffcebc6 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -114,7 +114,7 @@ func main() { scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, settingsController, eventBus) keyBindingService := keybindings_service.NewService(keyBindings) - keyBindingController := controllers.NewKeyBindingController(keyBindingService) + keyBindingController := controllers.NewKeyBindingController(keyBindingService, scriptController) commandController := commandctrl.NewCommandController(inputHistoryService) commandController.AddCommandLookupExtension(scriptController) diff --git a/internal/dynamo-browse/controllers/iface.go b/internal/dynamo-browse/controllers/iface.go index 3673221..6f00ad5 100644 --- a/internal/dynamo-browse/controllers/iface.go +++ b/internal/dynamo-browse/controllers/iface.go @@ -3,6 +3,7 @@ package controllers import ( "context" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + tea "github.com/charmbracelet/bubbletea" "github.com/lmika/audax/internal/dynamo-browse/models" "io/fs" ) @@ -25,3 +26,10 @@ type SettingsProvider interface { SetScriptLookupPaths(value string) error ScriptLookupPaths() string } + +type CustomKeyBindingSource interface { + LookupBinding(theKey string) string + CustomKeyCommand(key string) tea.Cmd + UnbindKey(key string) + Rebind(bindingName string, newKey string) error +} diff --git a/internal/dynamo-browse/controllers/jobbuilder.go b/internal/dynamo-browse/controllers/jobbuilder.go index 25f8e9b..7455b58 100644 --- a/internal/dynamo-browse/controllers/jobbuilder.go +++ b/internal/dynamo-browse/controllers/jobbuilder.go @@ -4,6 +4,7 @@ import ( "context" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/dynamo-browse/services/jobs" ) func NewJob[T any](jc *JobsController, description string, job func(ctx context.Context) (T, error)) JobBuilder[T] { @@ -61,7 +62,7 @@ func (jb JobBuilder[T]) executeJob(ctx context.Context) tea.Msg { } func (jb JobBuilder[T]) doSubmit() tea.Msg { - jb.jc.service.SubmitForegroundJob(func(ctx context.Context) { + if err := jb.jc.service.SubmitForegroundJob(jobs.WithDescription(jb.description, jobs.JobFunc(func(ctx context.Context) { msg := jb.executeJob(ctx) jb.jc.msgSender(msg) @@ -73,12 +74,9 @@ func (jb JobBuilder[T]) doSubmit() tea.Msg { JobStatus: "", }) } - }, func(msg string) { - jb.jc.msgSender(events.ForegroundJobUpdate{ - JobRunning: true, - JobStatus: jb.description + " " + msg, - }) - }) + }))); err != nil { + return events.Error(err) + } return events.ForegroundJobUpdate{ JobRunning: true, diff --git a/internal/dynamo-browse/controllers/jobs.go b/internal/dynamo-browse/controllers/jobs.go index b358426..063d9e5 100644 --- a/internal/dynamo-browse/controllers/jobs.go +++ b/internal/dynamo-browse/controllers/jobs.go @@ -5,6 +5,7 @@ import ( "github.com/lmika/audax/internal/common/ui/events" "github.com/lmika/audax/internal/dynamo-browse/services/jobs" bus "github.com/lmika/events" + "log" ) type JobsController struct { @@ -18,6 +19,9 @@ func NewJobsController(service *jobs.Services, bus *bus.Bus, immediate bool) *Jo service: service, immediate: immediate, } + bus.On(jobs.JobStartEvent, func(job jobs.EventData) { jc.sendForegroundJobState(job.Job, "") }) + bus.On(jobs.JobIdleEvent, func() { jc.sendForegroundJobState(nil, "") }) + bus.On(jobs.JobUpdateEvent, func(job jobs.EventData, update string) { jc.sendForegroundJobState(job.Job, update) }) return jc } @@ -36,3 +40,30 @@ func (js *JobsController) CancelRunningJob(ifNoJobsRunning func() tea.Msg) tea.M } return ifNoJobsRunning() } + +func (jc *JobsController) sendForegroundJobState(job jobs.Job, update string) { + if job == nil { + log.Printf("job service idle") + jc.msgSender(events.ForegroundJobUpdate{ + JobRunning: false, + }) + return + } + + var statusMessage string + if dj, ok := job.(jobs.DescribableJob); ok { + statusMessage = dj.Description + } else { + statusMessage = "Working…" + } + + if len(update) > 0 { + statusMessage += " " + update + } + log.Printf("job update: %v", statusMessage) + + jc.msgSender(events.ForegroundJobUpdate{ + JobRunning: true, + JobStatus: statusMessage, + }) +} diff --git a/internal/dynamo-browse/controllers/keybinding.go b/internal/dynamo-browse/controllers/keybinding.go index 2d62b65..9081026 100644 --- a/internal/dynamo-browse/controllers/keybinding.go +++ b/internal/dynamo-browse/controllers/keybinding.go @@ -9,32 +9,80 @@ import ( ) type KeyBindingController struct { - service *keybindings.Service + service *keybindings.Service + customBindingSource CustomKeyBindingSource } -func NewKeyBindingController(service *keybindings.Service) *KeyBindingController { - return &KeyBindingController{service: service} +func NewKeyBindingController(service *keybindings.Service, customBindingSource CustomKeyBindingSource) *KeyBindingController { + return &KeyBindingController{ + service: service, + customBindingSource: customBindingSource, + } } func (kb *KeyBindingController) Rebind(bindingName string, newKey string, force bool) tea.Msg { - err := kb.service.Rebind(bindingName, newKey, force) - if err == nil { + existingBinding := kb.findExistingBinding(newKey) + if existingBinding == "" { + if err := kb.rebind(bindingName, newKey); err != nil { + return events.Error(err) + } return events.StatusMsg(fmt.Sprintf("Binding '%v' now bound to '%v'", bindingName, newKey)) - } else if force { - return events.Error(errors.Wrapf(err, "cannot bind '%v' to '%v'", bindingName, newKey)) } - var keyAlreadyBoundErr keybindings.KeyAlreadyBoundError - if errors.As(err, &keyAlreadyBoundErr) { - promptMsg := fmt.Sprintf("Key '%v' already bound to '%v'. Continue? ", keyAlreadyBoundErr.Key, keyAlreadyBoundErr.ExistingBindingName) - return events.ConfirmYes(promptMsg, func() tea.Msg { - err := kb.service.Rebind(bindingName, newKey, true) - if err != nil { - return events.Error(err) - } - return events.StatusMsg(fmt.Sprintf("Binding '%v' now bound to '%v'", bindingName, newKey)) - }) - } + //err := kb.rebind(bindingName, newKey, force) + //if err == nil { + // return events.StatusMsg(fmt.Sprintf("Binding '%v' now bound to '%v'", bindingName, newKey)) + //} else if force { + // return events.Error(errors.Wrapf(err, "cannot bind '%v' to '%v'", bindingName, newKey)) + //} + // + //var keyAlreadyBoundErr keybindings.KeyAlreadyBoundError + //if errors.As(err, &keyAlreadyBoundErr) { + promptMsg := fmt.Sprintf("Key '%v' already bound to '%v'. Continue? ", newKey, existingBinding) + return events.ConfirmYes(promptMsg, func() tea.Msg { + kb.unbindKey(newKey) - return events.Error(err) + err := kb.rebind(bindingName, newKey) + if err != nil { + return events.Error(err) + } + return events.StatusMsg(fmt.Sprintf("Binding '%v' now bound to '%v'", bindingName, newKey)) + }) + //} + + //return events.Error(err) +} + +func (kb *KeyBindingController) rebind(bindingName string, newKey string) error { + err := kb.service.Rebind(bindingName, newKey) + if err == nil { + return nil + } + + var invalidBinding keybindings.InvalidBindingError + if !errors.As(err, &invalidBinding) { + return err + } + + return kb.customBindingSource.Rebind(bindingName, newKey) +} + +func (kb *KeyBindingController) unbindKey(key string) { + kb.service.UnbindKey(key) + kb.customBindingSource.UnbindKey(key) +} + +func (kb *KeyBindingController) findExistingBinding(key string) string { + if binding := kb.service.LookupBinding(key); binding != "" { + return binding + } + + return kb.customBindingSource.LookupBinding(key) +} + +func (kb *KeyBindingController) LookupCustomBinding(key string) tea.Cmd { + if kb.customBindingSource == nil { + return nil + } + return kb.customBindingSource.CustomKeyCommand(key) } diff --git a/internal/dynamo-browse/controllers/scripts.go b/internal/dynamo-browse/controllers/scripts.go index 73c9dbf..50daf23 100644 --- a/internal/dynamo-browse/controllers/scripts.go +++ b/internal/dynamo-browse/controllers/scripts.go @@ -214,3 +214,33 @@ func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanage } return newResultSet, nil } + +func (sc *ScriptController) CustomKeyCommand(key string) tea.Cmd { + _, cmd := sc.scriptManager.LookupKeyBinding(key) + if cmd == nil { + return nil + } + + return func() tea.Msg { + errChan := sc.waitAndPrintScriptError() + ctx := context.Background() + + if err := cmd.Invoke(ctx, nil, errChan); err != nil { + return events.Error(err) + } + return nil + } +} + +func (sc *ScriptController) Rebind(bindingName string, newKey string) error { + return sc.scriptManager.RebindKeyBinding(bindingName, newKey) +} + +func (sc *ScriptController) LookupBinding(theKey string) string { + bindingName, _ := sc.scriptManager.LookupKeyBinding(theKey) + return bindingName +} + +func (sc *ScriptController) UnbindKey(key string) { + sc.scriptManager.UnbindKey(key) +} diff --git a/internal/dynamo-browse/services/jobs/events.go b/internal/dynamo-browse/services/jobs/events.go deleted file mode 100644 index 0b1a1d4..0000000 --- a/internal/dynamo-browse/services/jobs/events.go +++ /dev/null @@ -1,9 +0,0 @@ -package jobs - -const ( - JobEventForegroundDone = "job_foreground_done" -) - -type JobDoneEvent struct { - Err error -} diff --git a/internal/dynamo-browse/services/jobs/jobs.go b/internal/dynamo-browse/services/jobs/jobs.go index 4e7291c..65eb35e 100644 --- a/internal/dynamo-browse/services/jobs/jobs.go +++ b/internal/dynamo-browse/services/jobs/jobs.go @@ -3,65 +3,60 @@ package jobs import ( "context" bus "github.com/lmika/events" + "github.com/pkg/errors" "sync" ) -type Job func(ctx context.Context) - type jobInfo struct { ctx context.Context + job Job cancelFn func() } type Services struct { - bus *bus.Bus + bus *bus.Bus + jobQueue chan Job mutex *sync.Mutex foregroundJob *jobInfo } func NewService(bus *bus.Bus) *Services { - return &Services{ - bus: bus, - mutex: new(sync.Mutex), + jc := &Services{ + bus: bus, + jobQueue: make(chan Job, 10), + mutex: new(sync.Mutex), } + go jc.waitForJobs() + return jc } // SubmitForegroundJob starts a foreground job. -func (jc *Services) SubmitForegroundJob(job Job, onJobUpdate func(msg string)) { - // TODO: if there's already a foreground job, then return error - - ctx, cancelFn := context.WithCancel(context.Background()) - - jobUpdateChan := make(chan string) - jobUpdater := &jobUpdaterValue{msgUpdate: jobUpdateChan} - ctx = context.WithValue(ctx, jobUpdaterKey, jobUpdater) - - newJobInfo := &jobInfo{ - ctx: ctx, - cancelFn: cancelFn, +func (jc *Services) SubmitForegroundJob(job Job) error { + select { + case jc.jobQueue <- job: + return nil + default: + return errors.New("too many jobs queued") } - // TODO: needs to be protected by the mutex +} + +func (jc *Services) setForegroundJob(newJobInfo *jobInfo) { + jc.mutex.Lock() jc.foregroundJob = newJobInfo + jc.mutex.Unlock() - go func() { - defer cancelFn() - defer close(jobUpdateChan) - - job(newJobInfo.ctx) - - // TODO: needs to be protected by the mutex - jc.foregroundJob = nil - }() - - go func() { - for update := range jobUpdateChan { - onJobUpdate(update) - } - }() + if newJobInfo != nil { + jc.bus.Fire(JobStartEvent, EventData{Job: newJobInfo.job}) + } else { + jc.bus.Fire(JobIdleEvent) + } } func (jc *Services) CancelForegroundJob() bool { + jc.mutex.Lock() + defer jc.mutex.Unlock() + // TODO: needs to be protected by the mutex if jc.foregroundJob != nil { // A nil cancel for a non-nil foreground job indicates that the cancellation function @@ -77,3 +72,46 @@ func (jc *Services) CancelForegroundJob() bool { return false } + +func (jc *Services) waitForJobs() { + ctx := context.Background() + + for job := range jc.jobQueue { + jc.runJob(ctx, job) + + if len(jc.jobQueue) == 0 { + jc.setForegroundJob(nil) + } + } +} + +func (jc *Services) runJob(ctx context.Context, job Job) { + ctx, cancelFn := context.WithCancel(context.Background()) + defer cancelFn() + + updateCloseChan := make(chan struct{}) + jobUpdateChan := make(chan string) + + jobUpdater := &jobUpdaterValue{msgUpdate: jobUpdateChan} + ctx = context.WithValue(ctx, jobUpdaterKey, jobUpdater) + + newJobInfo := &jobInfo{ + job: job, + ctx: ctx, + cancelFn: cancelFn, + } + jc.setForegroundJob(newJobInfo) + + go func() { + defer close(updateCloseChan) + + for update := range jobUpdateChan { + jc.bus.Fire(JobUpdateEvent, EventData{Job: job}, update) + } + }() + + job.Execute(newJobInfo.ctx) + + close(jobUpdateChan) + <-updateCloseChan +} diff --git a/internal/dynamo-browse/services/jobs/models.go b/internal/dynamo-browse/services/jobs/models.go new file mode 100644 index 0000000..9280f6b --- /dev/null +++ b/internal/dynamo-browse/services/jobs/models.go @@ -0,0 +1,32 @@ +package jobs + +import "context" + +const ( + JobStartEvent = "jobs.start" + JobIdleEvent = "jobs.idle" + JobUpdateEvent = "jobs.update" +) + +type EventData struct { + Job Job +} + +type Job interface { + Execute(ctx context.Context) +} + +type JobFunc func(ctx context.Context) + +func (jf JobFunc) Execute(ctx context.Context) { + jf(ctx) +} + +func WithDescription(description string, job Job) Job { + return DescribableJob{job, description} +} + +type DescribableJob struct { + Job + Description string +} diff --git a/internal/dynamo-browse/services/keybindings/errors.go b/internal/dynamo-browse/services/keybindings/errors.go index c989ffc..01e47c1 100644 --- a/internal/dynamo-browse/services/keybindings/errors.go +++ b/internal/dynamo-browse/services/keybindings/errors.go @@ -12,3 +12,9 @@ type KeyAlreadyBoundError struct { func (e KeyAlreadyBoundError) Error() string { return fmt.Sprintf("key '%v' already bound to '%v'", e.Key, e.ExistingBindingName) } + +type InvalidBindingError string + +func (e InvalidBindingError) Error() string { + return fmt.Sprintf("invalid binding: %v", string(e)) +} diff --git a/internal/dynamo-browse/services/keybindings/service.go b/internal/dynamo-browse/services/keybindings/service.go index c37de2d..153935e 100644 --- a/internal/dynamo-browse/services/keybindings/service.go +++ b/internal/dynamo-browse/services/keybindings/service.go @@ -2,8 +2,6 @@ package keybindings import ( "github.com/charmbracelet/bubbles/key" - "github.com/pkg/errors" - "log" "reflect" "strings" ) @@ -23,37 +21,56 @@ func NewService(keyBinding any) *Service { } } -func (s *Service) Rebind(name string, newKey string, force bool) error { - // Check if there already exists a binding (or clear it) +func (s *Service) LookupBinding(theKey string) string { var foundBinding = "" s.walkBindingFields(func(bindingName string, binding *key.Binding) bool { for _, boundKey := range binding.Keys() { - if boundKey == newKey { - if force { - // TODO: only filter out "boundKey" rather clear - log.Printf("clearing binding of %v", bindingName) + if boundKey == theKey { + foundBinding = bindingName + return false + } + } + return true + }) + return foundBinding +} + +func (s *Service) UnbindKey(theKey string) { + s.walkBindingFields(func(bindingName string, binding *key.Binding) bool { + for _, boundKey := range binding.Keys() { + if boundKey == theKey { + l := len(binding.Keys()) + if l == 1 { *binding = key.NewBinding() - return true - } else { - foundBinding = bindingName - return false + } else if l > 1 { + newKeys := make([]string, 0) + for _, k := range binding.Keys() { + if k != theKey { + newKeys = append(newKeys, k) + } + } + *binding = key.NewBinding(key.WithKeys(newKeys...)) } } } return true }) +} - if foundBinding != "" { - return KeyAlreadyBoundError{Key: newKey, ExistingBindingName: foundBinding} - } - +func (s *Service) Rebind(name string, newKey string) error { // Rebind binding := s.findFieldForBinding(name) if binding == nil { - return errors.Errorf("invalid binding: %v", name) + return InvalidBindingError(name) + } + + if len(binding.Keys()) == 0 { + *binding = key.NewBinding(key.WithKeys(newKey)) + } else { + newKeys := append([]string{newKey}, binding.Keys()...) + *binding = key.NewBinding(key.WithKeys(newKeys...)) } - *binding = key.NewBinding(key.WithKeys(newKey)) return nil } diff --git a/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go b/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go index 8332c3e..7ef842b 100644 --- a/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go +++ b/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.16.0. DO NOT EDIT. +// Code generated by mockery v2.20.0. DO NOT EDIT. package mocks @@ -29,6 +29,10 @@ func (_m *SessionService) Query(ctx context.Context, expr string, queryOptions s ret := _m.Called(ctx, expr, queryOptions) var r0 *models.ResultSet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, scriptmanager.QueryOptions) (*models.ResultSet, error)); ok { + return rf(ctx, expr, queryOptions) + } if rf, ok := ret.Get(0).(func(context.Context, string, scriptmanager.QueryOptions) *models.ResultSet); ok { r0 = rf(ctx, expr, queryOptions) } else { @@ -37,7 +41,6 @@ func (_m *SessionService) Query(ctx context.Context, expr string, queryOptions s } } - var r1 error if rf, ok := ret.Get(1).(func(context.Context, string, scriptmanager.QueryOptions) error); ok { r1 = rf(ctx, expr, queryOptions) } else { @@ -53,9 +56,9 @@ type SessionService_Query_Call struct { } // Query is a helper method to define mock.On call -// - ctx context.Context -// - expr string -// - queryOptions scriptmanager.QueryOptions +// - ctx context.Context +// - expr string +// - queryOptions scriptmanager.QueryOptions func (_e *SessionService_Expecter) Query(ctx interface{}, expr interface{}, queryOptions interface{}) *SessionService_Query_Call { return &SessionService_Query_Call{Call: _e.mock.On("Query", ctx, expr, queryOptions)} } @@ -72,6 +75,11 @@ func (_c *SessionService_Query_Call) Return(_a0 *models.ResultSet, _a1 error) *S return _c } +func (_c *SessionService_Query_Call) RunAndReturn(run func(context.Context, string, scriptmanager.QueryOptions) (*models.ResultSet, error)) *SessionService_Query_Call { + _c.Call.Return(run) + return _c +} + // ResultSet provides a mock function with given fields: ctx func (_m *SessionService) ResultSet(ctx context.Context) *models.ResultSet { ret := _m.Called(ctx) @@ -94,7 +102,7 @@ type SessionService_ResultSet_Call struct { } // ResultSet is a helper method to define mock.On call -// - ctx context.Context +// - ctx context.Context func (_e *SessionService_Expecter) ResultSet(ctx interface{}) *SessionService_ResultSet_Call { return &SessionService_ResultSet_Call{Call: _e.mock.On("ResultSet", ctx)} } @@ -111,6 +119,11 @@ func (_c *SessionService_ResultSet_Call) Return(_a0 *models.ResultSet) *SessionS return _c } +func (_c *SessionService_ResultSet_Call) RunAndReturn(run func(context.Context) *models.ResultSet) *SessionService_ResultSet_Call { + _c.Call.Return(run) + return _c +} + // SelectedItemIndex provides a mock function with given fields: ctx func (_m *SessionService) SelectedItemIndex(ctx context.Context) int { ret := _m.Called(ctx) @@ -131,7 +144,7 @@ type SessionService_SelectedItemIndex_Call struct { } // SelectedItemIndex is a helper method to define mock.On call -// - ctx context.Context +// - ctx context.Context func (_e *SessionService_Expecter) SelectedItemIndex(ctx interface{}) *SessionService_SelectedItemIndex_Call { return &SessionService_SelectedItemIndex_Call{Call: _e.mock.On("SelectedItemIndex", ctx)} } @@ -148,6 +161,11 @@ func (_c *SessionService_SelectedItemIndex_Call) Return(_a0 int) *SessionService return _c } +func (_c *SessionService_SelectedItemIndex_Call) RunAndReturn(run func(context.Context) int) *SessionService_SelectedItemIndex_Call { + _c.Call.Return(run) + return _c +} + // SetResultSet provides a mock function with given fields: ctx, newResultSet func (_m *SessionService) SetResultSet(ctx context.Context, newResultSet *models.ResultSet) { _m.Called(ctx, newResultSet) @@ -159,8 +177,8 @@ type SessionService_SetResultSet_Call struct { } // SetResultSet is a helper method to define mock.On call -// - ctx context.Context -// - newResultSet *models.ResultSet +// - ctx context.Context +// - newResultSet *models.ResultSet func (_e *SessionService_Expecter) SetResultSet(ctx interface{}, newResultSet interface{}) *SessionService_SetResultSet_Call { return &SessionService_SetResultSet_Call{Call: _e.mock.On("SetResultSet", ctx, newResultSet)} } @@ -177,6 +195,11 @@ func (_c *SessionService_SetResultSet_Call) Return() *SessionService_SetResultSe return _c } +func (_c *SessionService_SetResultSet_Call) RunAndReturn(run func(context.Context, *models.ResultSet)) *SessionService_SetResultSet_Call { + _c.Call.Return(run) + return _c +} + type mockConstructorTestingTNewSessionService interface { mock.TestingT Cleanup(func()) diff --git a/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go b/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go index 8a943d4..b029dd6 100644 --- a/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go +++ b/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.16.0. DO NOT EDIT. +// Code generated by mockery v2.20.0. DO NOT EDIT. package mocks @@ -32,8 +32,8 @@ type UIService_PrintMessage_Call struct { } // PrintMessage is a helper method to define mock.On call -// - ctx context.Context -// - msg string +// - ctx context.Context +// - msg string func (_e *UIService_Expecter) PrintMessage(ctx interface{}, msg interface{}) *UIService_PrintMessage_Call { return &UIService_PrintMessage_Call{Call: _e.mock.On("PrintMessage", ctx, msg)} } @@ -50,6 +50,11 @@ func (_c *UIService_PrintMessage_Call) Return() *UIService_PrintMessage_Call { return _c } +func (_c *UIService_PrintMessage_Call) RunAndReturn(run func(context.Context, string)) *UIService_PrintMessage_Call { + _c.Call.Return(run) + return _c +} + // Prompt provides a mock function with given fields: ctx, msg func (_m *UIService) Prompt(ctx context.Context, msg string) chan string { ret := _m.Called(ctx, msg) @@ -72,8 +77,8 @@ type UIService_Prompt_Call struct { } // Prompt is a helper method to define mock.On call -// - ctx context.Context -// - msg string +// - ctx context.Context +// - msg string func (_e *UIService_Expecter) Prompt(ctx interface{}, msg interface{}) *UIService_Prompt_Call { return &UIService_Prompt_Call{Call: _e.mock.On("Prompt", ctx, msg)} } @@ -90,6 +95,11 @@ func (_c *UIService_Prompt_Call) Return(_a0 chan string) *UIService_Prompt_Call return _c } +func (_c *UIService_Prompt_Call) RunAndReturn(run func(context.Context, string) chan string) *UIService_Prompt_Call { + _c.Call.Return(run) + return _c +} + type mockConstructorTestingTNewUIService interface { mock.TestingT Cleanup(func()) diff --git a/internal/dynamo-browse/services/scriptmanager/modext.go b/internal/dynamo-browse/services/scriptmanager/modext.go index 7977f21..f2f4f0c 100644 --- a/internal/dynamo-browse/services/scriptmanager/modext.go +++ b/internal/dynamo-browse/services/scriptmanager/modext.go @@ -2,10 +2,16 @@ package scriptmanager import ( "context" + "fmt" "github.com/cloudcmds/tamarin/arg" "github.com/cloudcmds/tamarin/object" "github.com/cloudcmds/tamarin/scope" "github.com/pkg/errors" + "regexp" +) + +var ( + validKeyBindingNames = regexp.MustCompile(`^[-a-zA-Z0-9_]+$`) ) type extModule struct { @@ -18,6 +24,7 @@ func (m *extModule) register(scp *scope.Scope) { modScope.AddBuiltins([]*object.Builtin{ object.NewBuiltin("command", m.command, mod), + object.NewBuiltin("key_binding", m.keyBinding, mod), }) scp.Declare("ext", mod, true) @@ -65,3 +72,64 @@ func (m *extModule) command(ctx context.Context, args ...object.Object) object.O m.scriptPlugin.definedCommands[cmdName] = &Command{plugin: m.scriptPlugin, cmdFn: newCommand} return nil } + +func (m *extModule) keyBinding(ctx context.Context, args ...object.Object) object.Object { + if err := arg.Require("ext.key_binding", 3, args); err != nil { + return err + } + + bindingName, err := object.AsString(args[0]) + if err != nil { + return err + } else if !validKeyBindingNames.MatchString(bindingName) { + return object.NewError(errors.New("value error: binding name must match regexp [-a-zA-Z0-9_]+")) + } + + options, err := object.AsMap(args[1]) + if err != nil { + return err + } + + var defaultKey string + if strVal, isStrVal := options.Get("default").(*object.String); isStrVal { + defaultKey = strVal.Value() + } + + fnRes, isFnRes := args[2].(*object.Function) + if !isFnRes { + return object.NewError(errors.New("expected second arg to be a function")) + } + + callFn, hasCallFn := object.GetCallFunc(ctx) + if !hasCallFn { + return object.NewError(errors.New("no callFn found in context")) + } + + // This command function will be executed by the script scheduler + newCommand := func(ctx context.Context, args []string) error { + objArgs := make([]object.Object, len(args)) + for i, a := range args { + objArgs[i] = object.NewString(a) + } + + ctx = ctxWithOptions(ctx, m.scriptPlugin.scriptService.options) + + res := callFn(ctx, fnRes.Scope(), fnRes, objArgs) + if object.IsError(res) { + errObj := res.(*object.Error) + return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, bindingName, errObj.Inspect()) + } + return nil + } + + fullBindingName := fmt.Sprintf("ext.%v.%v", m.scriptPlugin.name, bindingName) + + if m.scriptPlugin.definedKeyBindings == nil { + m.scriptPlugin.definedKeyBindings = make(map[string]*Command) + m.scriptPlugin.keyToKeyBinding = make(map[string]string) + } + + m.scriptPlugin.definedKeyBindings[fullBindingName] = &Command{plugin: m.scriptPlugin, cmdFn: newCommand} + m.scriptPlugin.keyToKeyBinding[defaultKey] = fullBindingName + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/modsession.go b/internal/dynamo-browse/services/scriptmanager/modsession.go index 510946c..034b73f 100644 --- a/internal/dynamo-browse/services/scriptmanager/modsession.go +++ b/internal/dynamo-browse/services/scriptmanager/modsession.go @@ -33,8 +33,15 @@ func (um *sessionModule) query(ctx context.Context, args ...object.Object) objec } // Table name - if val, isVal := objMap.Get("table").(*object.String); isVal && val.Value() != "" { - options.TableName = val.Value() + if val := objMap.Get("table"); val != object.Nil && val.IsTruthy() { + switch tv := val.(type) { + case *object.String: + options.TableName = tv.Value() + case *tableProxy: + options.TableName = tv.table.Name + default: + return object.Errorf("type error: query option 'table' must be either a string or table") + } } // Placeholders @@ -111,12 +118,26 @@ func (um *sessionModule) setResultSet(ctx context.Context, args ...object.Object return nil } +func (um *sessionModule) currentTable(ctx context.Context, args ...object.Object) object.Object { + if err := arg.Require("session.current_table", 0, args); err != nil { + return err + } + + rs := um.sessionService.ResultSet(ctx) + if rs == nil { + return object.Nil + } + + return &tableProxy{table: rs.TableInfo} +} + func (um *sessionModule) register(scp *scope.Scope) { modScope := scope.New(scope.Opts{}) mod := object.NewModule("session", modScope) modScope.AddBuiltins([]*object.Builtin{ object.NewBuiltin("query", um.query, mod), + object.NewBuiltin("current_table", um.currentTable, mod), object.NewBuiltin("result_set", um.resultSet, mod), object.NewBuiltin("selected_item", um.selectedItem, mod), object.NewBuiltin("set_result_set", um.setResultSet, mod), diff --git a/internal/dynamo-browse/services/scriptmanager/modsession_test.go b/internal/dynamo-browse/services/scriptmanager/modsession_test.go index 254e800..97ecc4e 100644 --- a/internal/dynamo-browse/services/scriptmanager/modsession_test.go +++ b/internal/dynamo-browse/services/scriptmanager/modsession_test.go @@ -12,6 +12,78 @@ import ( "testing" ) +func TestModSession_Table(t *testing.T) { + t.Run("should return details of the current table", func(t *testing.T) { + tableDef := models.TableInfo{ + Name: "test_table", + Keys: models.KeyAttribute{ + PartitionKey: "pk", + SortKey: "sk", + }, + GSIs: []models.TableGSI{ + { + Name: "index-1", + Keys: models.KeyAttribute{ + PartitionKey: "ipk", + SortKey: "isk", + }, + }, + }, + } + rs := models.ResultSet{TableInfo: &tableDef} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(&rs) + + testFS := testScriptFile(t, "test.tm", ` + table := session.current_table() + + assert(table.name == "test_table") + assert(table.keys["partition"] == "pk") + assert(table.keys["sort"] == "sk") + assert(len(table.gsis) == 1) + assert(table.gsis[0].name == "index-1") + assert(table.gsis[0].keys["partition"] == "ipk") + assert(table.gsis[0].keys["sort"] == "isk") + + assert(table == session.result_set().table) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return nil if no current result set", func(t *testing.T) { + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(nil) + + testFS := testScriptFile(t, "test.tm", ` + table := session.current_table() + + assert(table == nil) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + func TestModSession_Query(t *testing.T) { t.Run("should successfully return query result", func(t *testing.T) { rs := &models.ResultSet{} @@ -110,6 +182,42 @@ func TestModSession_Query(t *testing.T) { mockedSessionService.AssertExpectations(t) }) + t.Run("should successfully specify table proxy", func(t *testing.T) { + rs := &models.ResultSet{} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(&models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "some-resultset-table", + }, + }) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{ + TableName: "some-resultset-table", + }).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr", { + table: session.result_set().table, + }) + assert(!res.is_err()) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + t.Run("should set placeholder values", func(t *testing.T) { rs := &models.ResultSet{} diff --git a/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go index d00f7e4..92d8dca 100644 --- a/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go +++ b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go @@ -91,6 +91,8 @@ func (r *resultSetProxy) Iter() object.Iterator { func (r *resultSetProxy) GetAttr(name string) (object.Object, bool) { switch name { + case "table": + return &tableProxy{table: r.resultSet.TableInfo}, true case "length": return object.NewInt(int64(len(r.resultSet.Items()))), true } diff --git a/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go b/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go index 2c04967..9b5b87d 100644 --- a/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go +++ b/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go @@ -13,7 +13,11 @@ import ( func TestResultSetProxy(t *testing.T) { t.Run("should property return properties of a resultset and item", func(t *testing.T) { - rs := &models.ResultSet{} + rs := &models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "test-table", + }, + } rs.SetItems([]models.Item{ {"pk": &types.AttributeValueMemberS{Value: "abc"}}, {"pk": &types.AttributeValueMemberS{Value: "1232"}}, @@ -28,6 +32,8 @@ func TestResultSetProxy(t *testing.T) { res := session.query("some expr").unwrap() // Test properties of the result set + assert(res.table.name, "hello") + assert(res == res, "result_set.equals") assert(res.length == 2, "result_set.length") diff --git a/internal/dynamo-browse/services/scriptmanager/service.go b/internal/dynamo-browse/services/scriptmanager/service.go index 055a4de..7d68d28 100644 --- a/internal/dynamo-browse/services/scriptmanager/service.go +++ b/internal/dynamo-browse/services/scriptmanager/service.go @@ -4,10 +4,12 @@ import ( "context" "github.com/cloudcmds/tamarin/exec" "github.com/cloudcmds/tamarin/scope" + "github.com/lmika/audax/internal/dynamo-browse/services/keybindings" "github.com/pkg/errors" "io/fs" "os" "path/filepath" + "strings" ) type Service struct { @@ -119,7 +121,7 @@ func (s *Service) loadScript(ctx context.Context, filename string, resChan chan } newPlugin := &ScriptPlugin{ - name: filepath.Base(filename), + name: strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)), scriptService: s, } @@ -176,6 +178,49 @@ func (s *Service) LookupCommand(name string) *Command { return nil } +func (s *Service) LookupKeyBinding(key string) (string, *Command) { + for _, p := range s.plugins { + if bindingName, hasBinding := p.keyToKeyBinding[key]; hasBinding { + if cmd, hasCmd := p.definedKeyBindings[bindingName]; hasCmd { + return bindingName, cmd + } + } + } + return "", nil +} + +func (s *Service) UnbindKey(key string) { + for _, p := range s.plugins { + if _, hasBinding := p.keyToKeyBinding[key]; hasBinding { + delete(p.keyToKeyBinding, key) + } + } +} + +func (s *Service) RebindKeyBinding(keyBinding string, newKey string) error { + if newKey == "" { + for _, p := range s.plugins { + for k, b := range p.keyToKeyBinding { + if b == keyBinding { + delete(p.keyToKeyBinding, k) + } + } + } + return nil + } + + for _, p := range s.plugins { + if _, hasCmd := p.definedKeyBindings[keyBinding]; hasCmd { + if newKey != "" { + p.keyToKeyBinding[newKey] = keyBinding + } + return nil + } + } + + return keybindings.InvalidBindingError(keyBinding) +} + func (s *Service) parentScope() *scope.Scope { scp := scope.New(scope.Opts{}) (&uiModule{uiService: s.ifaces.UI}).register(scp) diff --git a/internal/dynamo-browse/services/scriptmanager/service_test.go b/internal/dynamo-browse/services/scriptmanager/service_test.go index 91ab574..1526434 100644 --- a/internal/dynamo-browse/services/scriptmanager/service_test.go +++ b/internal/dynamo-browse/services/scriptmanager/service_test.go @@ -55,7 +55,7 @@ func TestService_LoadScript(t *testing.T) { plugin, err := srv.LoadScript(ctx, "test.tm") assert.NoError(t, err) assert.NotNil(t, plugin) - assert.Equal(t, "test.tm", plugin.Name()) + assert.Equal(t, "test", plugin.Name()) cmd := srv.LookupCommand("somewhere") assert.NotNil(t, cmd) diff --git a/internal/dynamo-browse/services/scriptmanager/tableproxy.go b/internal/dynamo-browse/services/scriptmanager/tableproxy.go new file mode 100644 index 0000000..782f837 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/tableproxy.go @@ -0,0 +1,105 @@ +package scriptmanager + +import ( + "github.com/cloudcmds/tamarin/object" + "github.com/lmika/audax/internal/common/sliceutils" + "github.com/lmika/audax/internal/dynamo-browse/models" + "reflect" +) + +const ( + tableProxyPartitionKey = "partition" + tableProxySortKey = "sort" +) + +type tableProxy struct { + table *models.TableInfo +} + +func (t *tableProxy) Type() object.Type { + return "table" +} + +func (t *tableProxy) Inspect() string { + return "table(" + t.table.Name + ")" +} + +func (t *tableProxy) Interface() interface{} { + return t.table +} + +func (t *tableProxy) Equals(other object.Object) object.Object { + otherT, isOtherRS := other.(*tableProxy) + if !isOtherRS { + return object.False + } + + return object.NewBool(reflect.DeepEqual(t.table, otherT.table)) +} + +func (t *tableProxy) GetAttr(name string) (object.Object, bool) { + switch name { + case "name": + return object.NewString(t.table.Name), true + case "keys": + return object.NewMap(map[string]object.Object{ + tableProxyPartitionKey: object.NewString(t.table.Keys.PartitionKey), + tableProxySortKey: object.NewString(t.table.Keys.SortKey), + }), true + case "gsis": + return object.NewList(sliceutils.Map(t.table.GSIs, newTableIndexProxy)), true + } + + return nil, false +} + +func (t *tableProxy) IsTruthy() bool { + return true +} + +type tableIndexProxy struct { + gsi models.TableGSI +} + +func newTableIndexProxy(gsi models.TableGSI) object.Object { + return tableIndexProxy{gsi: gsi} +} + +func (t tableIndexProxy) Type() object.Type { + return "index" +} + +func (t tableIndexProxy) Inspect() string { + return "index(gsi," + t.gsi.Name + ")" +} + +func (t tableIndexProxy) Interface() interface{} { + return t.gsi +} + +func (t tableIndexProxy) Equals(other object.Object) object.Object { + otherIP, isOtherIP := other.(tableIndexProxy) + if !isOtherIP { + return object.False + } + + return object.NewBool(reflect.DeepEqual(t.gsi, otherIP.gsi)) +} + +func (t tableIndexProxy) GetAttr(name string) (object.Object, bool) { + switch name { + case "name": + return object.NewString(t.gsi.Name), true + case "keys": + return object.NewMap(map[string]object.Object{ + tableProxyPartitionKey: object.NewString(t.gsi.Keys.PartitionKey), + tableProxySortKey: object.NewString(t.gsi.Keys.SortKey), + }), true + } + + return nil, false +} + +func (t tableIndexProxy) IsTruthy() bool { + return true +} diff --git a/internal/dynamo-browse/services/scriptmanager/types.go b/internal/dynamo-browse/services/scriptmanager/types.go index ffdb567..b0bf1a0 100644 --- a/internal/dynamo-browse/services/scriptmanager/types.go +++ b/internal/dynamo-browse/services/scriptmanager/types.go @@ -3,9 +3,11 @@ package scriptmanager import "context" type ScriptPlugin struct { - scriptService *Service - name string - definedCommands map[string]*Command + scriptService *Service + name string + definedCommands map[string]*Command + definedKeyBindings map[string]*Command + keyToKeyBinding map[string]string } func (sp *ScriptPlugin) Name() string { diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 2e7a9ab..ef5d5da 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -54,11 +54,12 @@ type Model struct { mainViewIndex int - root tea.Model - tableView *dynamotableview.Model - itemView *dynamoitemview.Model - mainView tea.Model - keyMap *keybindings.ViewKeyBindings + root tea.Model + tableView *dynamotableview.Model + itemView *dynamoitemview.Model + mainView tea.Model + keyMap *keybindings.ViewKeyBindings + keyBindingController *controllers.KeyBindingController } func NewModel( @@ -231,6 +232,7 @@ func NewModel( itemView: div, mainView: mainView, keyMap: defaultKeyMap.View, + keyBindingController: keyBindingController, } } @@ -285,6 +287,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, events.SetTeaMessage(m.jobController.CancelRunningJob(m.promptToQuit)) case key.Matches(msg, m.keyMap.Quit): return m, m.promptToQuit + default: + if cmd := m.keyBindingController.LookupCustomBinding(msg.String()); cmd != nil { + return m, cmd + } } } }