Compare commits

...

10 commits

Author SHA1 Message Date
Leon Mika cae7509a76 Almost feature complete
- Added reading of UCL scripts
- Added pasteboard commands
- Added ui:command which will define a proc at the top-level
2025-05-25 13:31:00 +10:00
Leon Mika 7ae99b009b ucl: added rs:set 2025-05-23 22:04:41 +10:00
Leon Mika 5088009672 ucl: added more resultset functions 2025-05-19 22:14:22 +10:00
Leon Mika 40f8dd76e2 ucl: Have started adding some of the psudo variables 2025-05-18 13:42:44 +10:00
Leon Mika 18ffe85a56 First attempt at a resultset pseudovar
The resultset needs a table set, so rs:new will also assume the current table.
2025-05-17 22:16:49 +10:00
Leon Mika 6bf721873b Added rs:new and rs:query 2025-05-17 11:11:04 +10:00
Leon Mika cb908ec4eb Added back the core interactive commands 2025-05-16 18:01:28 +10:00
Leon Mika 17381f3d0b Started re-engineering the UCL command instance 2025-05-15 22:16:02 +10:00
Leon Mika 94b58e2168 Updated UCL and added an interactive mode 2024-05-04 11:40:24 +10:00
Leon Mika 29d425c77e Fixed deadlock with message listener 2024-05-01 21:45:47 +10:00
28 changed files with 2261 additions and 265 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
debug.log
.DS_store
.idea

View file

@ -4,9 +4,11 @@ import (
"context"
"flag"
"fmt"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks"
"log"
"net"
"os"
"strings"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
@ -44,6 +46,7 @@ func main() {
var flagDefaultLimit = flag.Int("default-limit", 0, "default limit for queries and scans")
var flagWorkspace = flag.String("w", "", "workspace file")
var flagQuery = flag.String("q", "", "run query")
var flagExtDir = flag.String("ext-dir", "$HOME/.config/dynamo-browse/ext:$HOME/.config/dynamo-browse/.", "directory to search for extensions")
flag.Parse()
ctx := context.Background()
@ -127,7 +130,7 @@ func main() {
exportController := controllers.NewExportController(state, tableService, jobsController, columnsController, pasteboardProvider)
settingsController := controllers.NewSettingsController(settingStore, eventBus)
keyBindings := keybindings.Default()
scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, jobsController, settingsController, eventBus)
//scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, jobsController, settingsController, eventBus)
if *flagQuery != "" {
if *flagTable == "" {
@ -157,10 +160,20 @@ func main() {
}
keyBindingService := keybindings_service.NewService(keyBindings)
keyBindingController := controllers.NewKeyBindingController(keyBindingService, scriptController)
keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil)
commandController := commandctrl.NewCommandController(inputHistoryService)
commandController.AddCommandLookupExtension(scriptController)
stdCommands := cmdpacks.NewStandardCommands(
tableService,
state,
tableReadController,
tableWriteController,
exportController,
keyBindingController,
pasteboardProvider,
)
commandController := commandctrl.NewCommandController(inputHistoryService, stdCommands)
//commandController.AddCommandLookupExtension(scriptController)
commandController.SetCommandCompletionProvider(columnsController)
model := ui.NewModel(
@ -172,12 +185,13 @@ func main() {
jobsController,
itemRendererService,
commandController,
scriptController,
//scriptController,
eventBus,
keyBindingController,
pasteboardProvider,
keyBindings,
)
commandController.SetUIStateProvider(&model)
// Pre-determine if layout has dark background. This prevents calls for creating a list to hang.
osstyle.DetectCurrentScheme()
@ -185,9 +199,14 @@ func main() {
p := tea.NewProgram(model, tea.WithAltScreen())
jobsController.SetMessageSender(p.Send)
scriptController.Init()
scriptController.SetMessageSender(p.Send)
commandController.SetMessageSender(p.Send)
//scriptController.Init()
//scriptController.SetMessageSender(p.Send)
if err := commandController.LoadExtensions(context.Background(), strings.Split(*flagExtDir, string(os.PathListSeparator))); err != nil {
fmt.Printf("Unable to load extensions: %v", err)
}
go commandController.StartMessageSender(p.Send)
log.Println("launching")
if err := p.Start(); err != nil {

6
go.mod
View file

@ -1,8 +1,8 @@
module github.com/lmika/dynamo-browse
go 1.22
go 1.24
toolchain go1.22.0
toolchain go1.24.0
require (
github.com/alecthomas/participle/v2 v2.1.1
@ -117,5 +117,5 @@ require (
golang.org/x/text v0.9.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
ucl.lmika.dev v0.0.0-20240501110514-25594c80d273 // indirect
ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e // indirect
)

32
go.sum
View file

@ -5,6 +5,8 @@ github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwS
github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879 h1:M5ptEKnqKqpFTKbe+p5zEf3ro1deJ6opUz5j3g3/ErQ=
github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/alecthomas/assert/v2 v2.0.3 h1:WKqJODfOiQG0nEJKFKzDIG3E29CN2/4zR9XGJzKIkbg=
github.com/alecthomas/participle v0.7.1 h1:2bN7reTw//5f0cugJcTOnY/NYZcWQOaajW+BwZB5xWs=
github.com/alecthomas/participle v0.7.1/go.mod h1:HfdmEuwvr12HXQN44HPWXR0lHmVolVYe4dyL6lQ3duY=
github.com/alecthomas/participle/v2 v2.0.0-beta.5 h1:y6dsSYVb1G5eK6mgmy+BgI3Mw35a3WghArZ/Hbebrjo=
github.com/alecthomas/participle/v2 v2.0.0-beta.5/go.mod h1:RC764t6n4L8D8ITAJv0qdokritYSNR3wV5cVwmIEaMM=
github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8=
@ -430,3 +432,33 @@ ucl.lmika.dev v0.0.0-20240427010304-6315afc54287 h1:llPHrjca54duvQx9PgMTFDhOW2VQ
ucl.lmika.dev v0.0.0-20240427010304-6315afc54287/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4=
ucl.lmika.dev v0.0.0-20240501110514-25594c80d273 h1:+JpKw02VTAcOjJw7Q6juun/9hk9ypNSdTRlf+E4M5Nw=
ucl.lmika.dev v0.0.0-20240501110514-25594c80d273/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4=
ucl.lmika.dev v0.0.0-20240504001444-cf3a12bf0d4d h1:OqGmR0Y+OG6aFIOlXy2QwEHtuUNasYCh/6cxHokYQj4=
ucl.lmika.dev v0.0.0-20240504001444-cf3a12bf0d4d/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4=
ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281 h1:/M7phiv/0XVp3wKkOxEnGQysf8+RS6NOaBQZyUEoSsA=
ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4=
ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8 h1:vWttdW8GJWcTUQeJFbQHqCHJDLFWQ9nccUTx/lW2v8s=
ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8/go.mod h1:FMP2ncSu4UxfvB0iA2zlebwL+1UPCARdyYNOrmi86A4=
ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2 h1:cvguOoQ0HVgLKbHH17ZHvAUFht6HXApLi0o8JOdaaNU=
ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250517003439-109be33d1495 h1:r46r+7T59Drm+in7TEWKCZfFYIM0ZyZ26QjHAbj8Lto=
ucl.lmika.dev v0.0.0-20250517003439-109be33d1495/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250517115116-0f1ceba0902e h1:CQ+qPqI5lYiiEM0tNAr4jS0iMz16bFqOui5mU3AHsCU=
ucl.lmika.dev v0.0.0-20250517115116-0f1ceba0902e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250517212052-51e35aa9a675 h1:kGKh3zj6lMzOrGAquFW7ROgx9/6nwJ8DXiSLtceRiak=
ucl.lmika.dev v0.0.0-20250517212052-51e35aa9a675/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250517212757-33d04ba18db4 h1:rnietWu2B+NXLqKfo7jgf6r+srMwxFa5eizywkq4LFk=
ucl.lmika.dev v0.0.0-20250517212757-33d04ba18db4/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250517213937-94aad417121d h1:CMcA8aQV6iiPK75EbHvoIVZhZmSggfrWNhK9BFm2aIg=
ucl.lmika.dev v0.0.0-20250517213937-94aad417121d/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94 h1:x3IRtT1jbedblimi2hesKGBihg243+wNOSvagCPR0KU=
ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78 h1:lbOZUb6whYMLI4win5QL+eLSgqc3N9TtTgT8hTipNl8=
ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250519111943-1173d163f5e3 h1:ZMQ1rkcAWa///c3bVvlXbtuqjfAWxDm01abQl3g/YVw=
ucl.lmika.dev v0.0.0-20250519111943-1173d163f5e3/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a h1:dzBBFCY50+MQcJaQ90swdDyjzag5oIhwdfqbmZkvX3Q=
ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8 h1:h32JQi0d1MI86RaAMaEU7kvti4uSLX5XYe/nk2abApg=
ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e h1:N+HzQUunDUvdjAzbSDtHQZVZ1k+XHbVgbNwmc+EKmlQ=
ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=

View file

@ -0,0 +1,46 @@
package cmdpacks
import (
"context"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider"
"ucl.lmika.dev/ucl"
)
type pbModule struct {
pasteboardProvider *pasteboardprovider.Provider
}
func (m pbModule) pbGet(ctx context.Context, args ucl.CallArgs) (any, error) {
s, ok := m.pasteboardProvider.ReadText()
if !ok {
return "", nil
}
return s, nil
}
func (m pbModule) pbPut(ctx context.Context, args ucl.CallArgs) (any, error) {
var s string
if err := args.Bind(&s); err != nil {
return nil, err
}
if err := m.pasteboardProvider.WriteText([]byte(s)); err != nil {
return nil, err
}
return s, nil
}
func modulePB(
pasteboardProvider *pasteboardprovider.Provider,
) ucl.Module {
m := &pbModule{
pasteboardProvider: pasteboardProvider,
}
return ucl.Module{
Name: "pb",
Builtins: map[string]ucl.BuiltinHandler{
"get": m.pbGet,
"put": m.pbPut,
},
}
}

View file

@ -0,0 +1,308 @@
package cmdpacks
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"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/services/tables"
"github.com/pkg/errors"
"time"
"ucl.lmika.dev/repl"
"ucl.lmika.dev/ucl"
)
type rsModule struct {
tableService *tables.Service
state *controllers.State
}
var rsNewDoc = repl.Doc{
Brief: "Creates a new, empty result set",
Usage: "[-table NAME]",
Detailed: `
The result set assumes the details of the current table. If no table is specified,
the command will return an error.
`,
}
func (rs *rsModule) rsNew(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var tableInfo *models.TableInfo
if args.HasSwitch("table") {
var tblName string
if err := args.BindSwitch("table", &tblName); err != nil {
return nil, err
}
tableInfo, err = rs.tableService.Describe(ctx, tblName)
if err != nil {
return nil, err
}
} else if currRs := rs.state.ResultSet(); currRs != nil && currRs.TableInfo != nil {
tableInfo = currRs.TableInfo
} else {
return nil, errors.New("no table specified")
}
return newResultSetProxy(&models.ResultSet{
TableInfo: tableInfo,
Created: time.Now(),
}), nil
}
var rsQueryDoc = repl.Doc{
Brief: "Runs a query and returns the results as a result-set",
Usage: "QUERY [ARGS] [-table NAME]",
Args: []repl.ArgDoc{
{Name: "query", Brief: "Query expression to run"},
{Name: "args", Brief: "Hash of argument values to substitute into the query"},
{Name: "-table", Brief: "Optional table name to use for the query"},
},
Detailed: `
If no table is specified, then the value of @table will be used. If this is unavailable,
the command will return an error.
`,
}
func parseQuery(
ctx context.Context,
args ucl.CallArgs,
currentRS *models.ResultSet,
tablesService *tables.Service,
) (*queryexpr.QueryExpr, *models.TableInfo, error) {
var expr string
if err := args.Bind(&expr); err != nil {
return nil, nil, err
}
q, err := queryexpr.Parse(expr)
if err != nil {
return nil, nil, err
}
if args.NArgs() > 0 {
var queryArgs ucl.Hashable
if err := args.Bind(&queryArgs); err != nil {
return nil, nil, err
}
queryNames := map[string]string{}
queryValues := map[string]types.AttributeValue{}
queryArgs.Each(func(k string, v ucl.Object) error {
if v == nil {
return nil
}
queryNames[k] = v.String()
switch v.(type) {
case ucl.StringObject:
queryValues[k] = &types.AttributeValueMemberS{Value: v.String()}
case ucl.IntObject:
queryValues[k] = &types.AttributeValueMemberN{Value: v.String()}
// TODO: other types
}
return nil
})
q = q.WithNameParams(queryNames).WithValueParams(queryValues)
}
var tableInfo *models.TableInfo
if args.HasSwitch("table") {
var tblName string
if err := args.BindSwitch("table", &tblName); err != nil {
return nil, nil, err
}
tableInfo, err = tablesService.Describe(ctx, tblName)
if err != nil {
return nil, nil, err
}
} else if currentRS != nil && currentRS.TableInfo != nil {
tableInfo = currentRS.TableInfo
} else {
return nil, nil, errors.New("no table specified")
}
return q, tableInfo, nil
}
func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) {
q, tableInfo, err := parseQuery(ctx, args, rs.state.ResultSet(), rs.tableService)
if err != nil {
return nil, err
}
newResultSet, err := rs.tableService.ScanOrQuery(context.Background(), tableInfo, q, nil)
if err != nil {
return nil, err
}
return newResultSetProxy(newResultSet), nil
}
var rsScanDoc = repl.Doc{
Brief: "Performs a scan of the table and returns the results as a result-set",
Usage: "[-table NAME]",
Args: []repl.ArgDoc{
{Name: "-table", Brief: "Optional table name to use for the query"},
},
Detailed: `
If no table is specified, then the value of @table will be used. If this is unavailable,
the command will return an error.
`,
}
func (rs *rsModule) rsScan(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var tableInfo *models.TableInfo
if args.HasSwitch("table") {
var tblName string
if err := args.BindSwitch("table", &tblName); err != nil {
return nil, err
}
tableInfo, err = rs.tableService.Describe(ctx, tblName)
if err != nil {
return nil, err
}
} else if currRs := rs.state.ResultSet(); currRs != nil && currRs.TableInfo != nil {
tableInfo = currRs.TableInfo
} else {
return nil, errors.New("no table specified")
}
newResultSet, err := rs.tableService.Scan(context.Background(), tableInfo)
if err != nil {
return nil, err
}
return newResultSetProxy(newResultSet), nil
}
func (rs *rsModule) rsFilter(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
rsProxy SimpleProxy[*models.ResultSet]
filter string
)
if err := args.Bind(&rsProxy, &filter); err != nil {
return nil, err
}
newResultSet := rs.tableService.Filter(rsProxy.ProxyValue(), filter)
return newResultSetProxy(newResultSet), nil
}
var rsNextPageDoc = repl.Doc{
Brief: "Returns the next page of the passed in result-set",
Usage: "RESULT_SET",
Args: []repl.ArgDoc{
{Name: "result-set", Brief: "Result set to fetch the next page of"},
},
Detailed: `
If no next page exists, the command will return nil.
`,
}
func (rs *rsModule) rsNextPage(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var rsProxy SimpleProxy[*models.ResultSet]
if err := args.Bind(&rsProxy); err != nil {
return nil, err
}
if !rsProxy.value.HasNextPage() {
return nil, nil
}
nextPage, err := rs.tableService.NextPage(ctx, rsProxy.value)
if err != nil {
return nil, err
}
return newResultSetProxy(nextPage), nil
}
func (rs *rsModule) rsUnion(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var rsProxy1, rsProxy2 SimpleProxy[*models.ResultSet]
if err := args.Bind(&rsProxy1, &rsProxy2); err != nil {
return nil, err
}
return newResultSetProxy(rsProxy1.ProxyValue().MergeWith(rsProxy2.ProxyValue())), nil
}
func (rs *rsModule) rsSet(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var (
item itemProxy
expr string
val ucl.Object
)
if err := args.Bind(&item, &expr, &val); err != nil {
return nil, err
}
q, err := queryexpr.Parse(expr)
if err != nil {
return nil, err
}
// TEMP: attribute is always S
if err := q.SetEvalItem(item.item, &types.AttributeValueMemberS{Value: val.String()}); err != nil {
return nil, err
}
item.resultSet.SetDirty(item.idx, true)
commandctrl.QueueRefresh(ctx)
return item, nil
}
func (rs *rsModule) rsDel(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var (
item itemProxy
expr string
)
if err := args.Bind(&item, &expr); err != nil {
return nil, err
}
q, err := queryexpr.Parse(expr)
if err != nil {
return nil, err
}
if err := q.DeleteAttribute(item.item); err != nil {
return nil, err
}
item.resultSet.SetDirty(item.idx, true)
commandctrl.QueueRefresh(ctx)
return item, nil
}
func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module {
m := &rsModule{
tableService: tableService,
state: state,
}
return ucl.Module{
Name: "rs",
Builtins: map[string]ucl.BuiltinHandler{
"new": m.rsNew,
"query": m.rsQuery,
"scan": m.rsScan,
"filter": m.rsFilter,
"next-page": m.rsNextPage,
"union": m.rsUnion,
"set": m.rsSet,
"del": m.rsDel,
},
}
}

View file

@ -0,0 +1,154 @@
package cmdpacks_test
import (
"fmt"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/stretchr/testify/assert"
"testing"
)
func TestModRS_New(t *testing.T) {
svc := newService(t)
rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:new`)
assert.NoError(t, err)
assert.IsType(t, rsProxy, cmdpacks.SimpleProxy[*models.ResultSet]{})
}
func TestModRS_NextPage(t *testing.T) {
t.Run("multiple pages", func(t *testing.T) {
svc := newService(t, withDataGenerator(largeTestData), withTable("large-table"), withDefaultLimit(20))
hasNextPage, err := svc.CommandController.ExecuteAndWait(t.Context(), `@resultset.HasNextPage`)
assert.NoError(t, err)
assert.True(t, hasNextPage.(bool))
// Page 2
rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset`)
assert.NoError(t, err)
assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy)
assert.Equal(t, 20, len(rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue().Items()))
hasNextPage, err = svc.CommandController.ExecuteAndWait(t.Context(), `(rs:next-page @resultset).HasNextPage`)
assert.NoError(t, err)
assert.True(t, hasNextPage.(bool))
// Page 3
rsProxy, err = svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset | rs:next-page`)
assert.NoError(t, err)
assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy)
assert.Equal(t, 10, len(rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue().Items()))
hasNextPage, err = svc.CommandController.ExecuteAndWait(t.Context(), `(rs:next-page @resultset | rs:next-page).HasNextPage`)
assert.NoError(t, err)
assert.False(t, hasNextPage.(bool))
// Last page
rsProxy, err = svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page (rs:next-page @resultset | rs:next-page)`)
assert.NoError(t, err)
assert.Nil(t, rsProxy)
})
t.Run("only one page", func(t *testing.T) {
svc := newService(t)
hasNextPage, err := svc.CommandController.ExecuteAndWait(t.Context(), `@resultset.HasNextPage`)
assert.NoError(t, err)
assert.False(t, hasNextPage.(bool))
rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset`)
assert.NoError(t, err)
assert.Nil(t, rsProxy)
})
}
func TestModRS_Union(t *testing.T) {
svc := newService(t, withDefaultLimit(2))
rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `
$mr = rs:union @resultset (rs:next-page @resultset)
assert (eq (len $mr.Items) 3) "expected len == 3"
assert (eq $mr.Items.(0).pk "abc") "expected 0.pk"
assert (eq $mr.Items.(0).sk "111") "expected 0.sk"
assert (eq $mr.Items.(1).pk "abc") "expected 1.pk"
assert (eq $mr.Items.(1).sk "222") "expected 1.sk"
assert (eq $mr.Items.(2).pk "bbb") "expected 2.pk"
assert (eq $mr.Items.(2).sk "131") "expected 2.sk"
$mr
`)
assert.NoError(t, err)
assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy)
rs := rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue()
assert.Equal(t, 3, len(rs.Items()))
}
func TestModRS_Query(t *testing.T) {
tests := []struct {
descr string
cmd string
wantRows []int
}{
{
descr: "query with pk 1",
cmd: `rs:query 'pk="abc"' -table service-test-data`,
wantRows: []int{0, 1},
},
{
descr: "query with pk 2",
cmd: `rs:query 'pk="bbb"' -table service-test-data`,
wantRows: []int{2},
},
{
descr: "query with sk 1",
cmd: `rs:query 'sk="222"' -table service-test-data`,
wantRows: []int{1},
},
{
descr: "query with args 1",
cmd: `rs:query 'pk=$v' [v:'abc'] -table service-test-data`,
wantRows: []int{0, 1},
},
{
descr: "query with args 2",
cmd: `rs:query ':k=$v' [k:'pk' v:'abc'] -table service-test-data`,
wantRows: []int{0, 1},
},
{
descr: "query with args 3",
cmd: `rs:query ':k=$v' [k:'beta' v:1231] -table service-test-data`,
wantRows: []int{1},
},
{
descr: "query with args with no table set",
cmd: `rs:query ':k=$v' [k:'beta' v:1231]`,
wantRows: []int{1},
},
}
for _, tt := range tests {
t.Run(tt.descr, func(t *testing.T) {
svc := newService(t)
res, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd)
assert.NoError(t, err)
rs := res.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue()
assert.Len(t, rs.Items(), len(tt.wantRows))
for i, rowIndex := range tt.wantRows {
for key, want := range svc.testData[0].Data[rowIndex] {
have, ok := rs.Items()[i].AttributeValueAsString(key)
assert.True(t, ok)
assert.Equal(t, fmt.Sprint(want), have)
}
}
})
}
}

View file

@ -0,0 +1,210 @@
package cmdpacks
import (
"context"
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/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables"
"ucl.lmika.dev/ucl"
)
type uiModule struct {
tableService *tables.Service
state *controllers.State
ckb *customKeyBinding
readController *controllers.TableReadController
}
func (m *uiModule) uiCommand(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
name string
cmd ucl.Invokable
)
if err := args.Bind(&name, &cmd); err != nil {
return nil, err
}
invoker := commandctrl.GetInvoker(ctx)
invoker.Inst().SetBuiltinInvokable(name, cmd)
return nil, nil
}
func (m *uiModule) uiPrompt(ctx context.Context, args ucl.CallArgs) (any, error) {
var prompt string
if err := args.Bind(&prompt); err != nil {
return nil, err
}
resChan := make(chan string)
go func() {
commandctrl.PostMsg(ctx, events.PromptForInput(prompt, nil, func(value string) tea.Msg {
resChan <- value
return nil
}))
}()
select {
case value := <-resChan:
return value, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *uiModule) uiConfirm(ctx context.Context, args ucl.CallArgs) (any, error) {
var prompt string
if err := args.Bind(&prompt); err != nil {
return nil, err
}
resChan := make(chan bool)
go func() {
commandctrl.PostMsg(ctx, events.Confirm(prompt, func(value bool) tea.Msg {
resChan <- value
return nil
}))
}()
select {
case value := <-resChan:
return value, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *uiModule) uiPromptTable(ctx context.Context, args ucl.CallArgs) (any, error) {
tables, err := m.tableService.ListTables(context.Background())
if err != nil {
return nil, err
}
resChan := make(chan string)
go func() {
commandctrl.PostMsg(ctx, controllers.PromptForTableMsg{
Tables: tables,
OnSelected: func(tableName string) tea.Msg {
resChan <- tableName
return nil
},
})
}()
select {
case value := <-resChan:
return value, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *uiModule) uiBind(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
bindName string
key string
inv ucl.Invokable
)
if args.NArgs() == 2 {
if err := args.Bind(&key, &inv); err != nil {
return nil, err
}
bindName = "custom." + key
} else {
if err := args.Bind(&bindName, &key, &inv); err != nil {
return nil, err
}
}
invoker := commandctrl.GetInvoker(ctx)
m.ckb.bindings[bindName] = func() tea.Msg {
return invoker.Invoke(inv, nil)
}
m.ckb.keyBindings[key] = bindName
return nil, nil
}
func (m *uiModule) uiQuery(ctx context.Context, args ucl.CallArgs) (any, error) {
q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService)
if err != nil {
return nil, err
}
commandctrl.PostMsg(ctx, m.readController.RunQuery(q, tableInfo))
return nil, nil
}
func (m *uiModule) uiFilter(ctx context.Context, args ucl.CallArgs) (any, error) {
var filter string
if err := args.Bind(&filter); err != nil {
return nil, err
}
commandctrl.PostMsg(ctx, m.readController.Filter(filter))
return nil, nil
}
func moduleUI(
tableService *tables.Service,
state *controllers.State,
readController *controllers.TableReadController,
) (ucl.Module, controllers.CustomKeyBindingSource) {
m := &uiModule{
tableService: tableService,
state: state,
readController: readController,
ckb: &customKeyBinding{
bindings: map[string]tea.Cmd{},
keyBindings: map[string]string{},
},
}
return ucl.Module{
Name: "ui",
Builtins: map[string]ucl.BuiltinHandler{
"command": m.uiCommand,
"prompt": m.uiPrompt,
"prompt-table": m.uiPromptTable,
"confirm": m.uiConfirm,
"query": m.uiQuery,
"filter": m.uiFilter,
"bind": m.uiBind,
},
}, m.ckb
}
type customKeyBinding struct {
bindings map[string]tea.Cmd
keyBindings map[string]string
}
func (c *customKeyBinding) LookupBinding(theKey string) string {
return c.keyBindings[theKey]
}
func (c *customKeyBinding) CustomKeyCommand(key string) tea.Cmd {
bindingName, ok := c.keyBindings[key]
if !ok {
return nil
}
binding, ok := c.bindings[bindingName]
if !ok {
return nil
}
return binding
}
func (c *customKeyBinding) UnbindKey(key string) {
delete(c.keyBindings, key)
}
func (c *customKeyBinding) Rebind(bindingName string, newKey string) error {
c.keyBindings[newKey] = bindingName
return nil
}

View file

@ -0,0 +1,216 @@
package cmdpacks
import (
"fmt"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"maps"
"strconv"
"ucl.lmika.dev/ucl"
)
type proxyInfo[T comparable] struct {
fields map[string]func(t T) ucl.Object
lenFunc func(t T) int
strFunc func(t T) string
}
type SimpleProxy[T comparable] struct {
value T
proxyInfo *proxyInfo[T]
}
func (tp SimpleProxy[T]) ProxyValue() T {
return tp.value
}
func (tp SimpleProxy[T]) String() string {
if tp.proxyInfo.strFunc != nil {
return tp.proxyInfo.strFunc(tp.value)
}
return fmt.Sprint(tp.value)
}
func (tp SimpleProxy[T]) Truthy() bool {
var zeroT T
return tp.value != zeroT
}
func (tp SimpleProxy[T]) Len() int {
if tp.proxyInfo.lenFunc != nil {
return tp.proxyInfo.lenFunc(tp.value)
}
return len(tp.proxyInfo.fields)
}
func (tp SimpleProxy[T]) Value(k string) ucl.Object {
f, ok := tp.proxyInfo.fields[k]
if !ok {
return nil
}
return f(tp.value)
}
func (tp SimpleProxy[T]) Each(fn func(k string, v ucl.Object) error) error {
for key := range maps.Keys(tp.proxyInfo.fields) {
if err := fn(key, tp.Value(key)); err != nil {
return err
}
}
return nil
}
type simpleProxyList[T comparable] struct {
values []T
converter func(T) ucl.Object
}
func newSimpleProxyList[T comparable](values []T, converter func(T) ucl.Object) simpleProxyList[T] {
return simpleProxyList[T]{values: values, converter: converter}
}
func (tp simpleProxyList[T]) String() string {
return fmt.Sprint(tp.values)
}
func (tp simpleProxyList[T]) Truthy() bool {
return len(tp.values) > 0
}
func (tp simpleProxyList[T]) Len() int {
return len(tp.values)
}
func (tp simpleProxyList[T]) Index(k int) ucl.Object {
return tp.converter(tp.values[k])
}
func newResultSetProxy(rs *models.ResultSet) ucl.Object {
return SimpleProxy[*models.ResultSet]{value: rs, proxyInfo: resultSetProxyFields}
}
var resultSetProxyFields = &proxyInfo[*models.ResultSet]{
lenFunc: func(t *models.ResultSet) int { return len(t.Items()) },
strFunc: func(t *models.ResultSet) string {
return fmt.Sprintf("ResultSet(%v:%d)", t.TableInfo.Name, len(t.Items()))
},
fields: map[string]func(t *models.ResultSet) ucl.Object{
"Table": func(t *models.ResultSet) ucl.Object { return newTableProxy(t.TableInfo) },
"Items": func(t *models.ResultSet) ucl.Object { return resultSetItemsProxy{t} },
"HasNextPage": func(t *models.ResultSet) ucl.Object { return ucl.BoolObject(t.HasNextPage()) },
},
}
func newTableProxy(table *models.TableInfo) ucl.Object {
return SimpleProxy[*models.TableInfo]{value: table, proxyInfo: tableProxyFields}
}
var tableProxyFields = &proxyInfo[*models.TableInfo]{
strFunc: func(t *models.TableInfo) string {
return fmt.Sprintf("Table(%v)", t.Name)
},
fields: map[string]func(t *models.TableInfo) ucl.Object{
"Name": func(t *models.TableInfo) ucl.Object { return ucl.StringObject(t.Name) },
"Keys": func(t *models.TableInfo) ucl.Object { return newKeyAttributeProxy(t.Keys) },
"DefinedAttributes": func(t *models.TableInfo) ucl.Object { return ucl.StringListObject(t.DefinedAttributes) },
"GSIs": func(t *models.TableInfo) ucl.Object { return newSimpleProxyList(t.GSIs, newGSIProxy) },
},
}
func newKeyAttributeProxy(keyAttrs models.KeyAttribute) ucl.Object {
return SimpleProxy[models.KeyAttribute]{value: keyAttrs, proxyInfo: keyAttributeProxyFields}
}
var keyAttributeProxyFields = &proxyInfo[models.KeyAttribute]{
strFunc: func(t models.KeyAttribute) string {
return fmt.Sprintf("KeyAttribute(%v,%v)", t.PartitionKey, t.SortKey)
},
fields: map[string]func(t models.KeyAttribute) ucl.Object{
"PK": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) },
"SK": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) },
},
}
func newGSIProxy(gsi models.TableGSI) ucl.Object {
return SimpleProxy[models.TableGSI]{value: gsi, proxyInfo: gsiProxyFields}
}
var gsiProxyFields = &proxyInfo[models.TableGSI]{
strFunc: func(t models.TableGSI) string {
return fmt.Sprintf("TableGSI(%v,(%v,%v))", t.Name, t.Keys.PartitionKey, t.Keys.SortKey)
},
fields: map[string]func(t models.TableGSI) ucl.Object{
"Name": func(t models.TableGSI) ucl.Object { return ucl.StringObject(t.Name) },
"Keys": func(t models.TableGSI) ucl.Object { return newKeyAttributeProxy(t.Keys) },
},
}
type resultSetItemsProxy struct {
resultSet *models.ResultSet
}
func (ip resultSetItemsProxy) String() string {
return "RSItem()"
}
func (ip resultSetItemsProxy) Truthy() bool {
return len(ip.resultSet.Items()) > 0
}
func (tp resultSetItemsProxy) Len() int {
return len(tp.resultSet.Items())
}
func (tp resultSetItemsProxy) Index(k int) ucl.Object {
return itemProxy{resultSet: tp.resultSet, idx: k, item: tp.resultSet.Items()[k]}
}
type itemProxy struct {
resultSet *models.ResultSet
idx int
item models.Item
}
func (ip itemProxy) String() string {
return fmt.Sprintf("RSItems(%v)", len(ip.item))
}
func (ip itemProxy) Truthy() bool {
return len(ip.item) > 0
}
func (tp itemProxy) Len() int {
return len(tp.item)
}
func (tp itemProxy) Value(k string) ucl.Object {
f, ok := tp.item[k]
if !ok {
return nil
}
return convertAttributeValueToUCLObject(f)
}
func (tp itemProxy) Each(fn func(k string, v ucl.Object) error) error {
for key := range maps.Keys(tp.item) {
if err := fn(key, tp.Value(key)); err != nil {
return err
}
}
return nil
}
func convertAttributeValueToUCLObject(attrValue types.AttributeValue) ucl.Object {
switch t := attrValue.(type) {
case *types.AttributeValueMemberS:
return ucl.StringObject(t.Value)
case *types.AttributeValueMemberN:
i, err := strconv.ParseInt(t.Value, 10, 64)
if err != nil {
return nil
}
return ucl.IntObject(i)
}
// TODO: the rest
return nil
}

View file

@ -0,0 +1,62 @@
package cmdpacks
import (
"context"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/pkg/errors"
)
type tablePVar struct {
state *controllers.State
}
func (rs tablePVar) Get(ctx context.Context) (any, error) {
return newTableProxy(rs.state.ResultSet().TableInfo), nil
}
type resultSetPVar struct {
state *controllers.State
readController *controllers.TableReadController
}
func (rs resultSetPVar) Get(ctx context.Context) (any, error) {
return newResultSetProxy(rs.state.ResultSet()), nil
}
func (rs resultSetPVar) Set(ctx context.Context, value any) error {
rsVal, ok := value.(SimpleProxy[*models.ResultSet])
if !ok {
return errors.New("new value to @resultset is nil or not a result set")
}
msg := rs.readController.SetResultSet(rsVal.value)
commandctrl.PostMsg(ctx, msg)
return nil
}
type itemPVar struct {
state *controllers.State
}
func (rs itemPVar) Get(ctx context.Context) (any, error) {
selItem, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
return itemProxy{rs.state.ResultSet(), selItem, rs.state.ResultSet().Items()[selItem]}, nil
}
func (rs itemPVar) Set(ctx context.Context, value any) error {
rsVal, ok := value.(itemProxy)
if !ok {
return errors.New("new value to @item is not an item")
}
if msg := commandctrl.SetSelectedItemIndex(ctx, rsVal.idx); msg != nil {
commandctrl.PostMsg(ctx, msg)
}
return nil
}

View file

@ -0,0 +1,421 @@
package cmdpacks
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables"
"github.com/pkg/errors"
"ucl.lmika.dev/repl"
"ucl.lmika.dev/ucl"
)
type StandardCommands struct {
TableService *tables.Service
State *controllers.State
ReadController *controllers.TableReadController
WriteController *controllers.TableWriteController
ExportController *controllers.ExportController
KeyBindingController *controllers.KeyBindingController
PBProvider *pasteboardprovider.Provider
modUI ucl.Module
}
func NewStandardCommands(
tableService *tables.Service,
state *controllers.State,
readController *controllers.TableReadController,
writeController *controllers.TableWriteController,
exportController *controllers.ExportController,
keyBindingController *controllers.KeyBindingController,
pbProvider *pasteboardprovider.Provider,
) StandardCommands {
modUI, ckbs := moduleUI(tableService, state, readController)
keyBindingController.SetCustomKeyBindingSource(ckbs)
return StandardCommands{
TableService: tableService,
State: state,
ReadController: readController,
WriteController: writeController,
ExportController: exportController,
KeyBindingController: keyBindingController,
PBProvider: pbProvider,
modUI: modUI,
}
}
var cmdQuitDoc = repl.Doc{
Brief: "Quits dynamo-browse",
Detailed: `
This will quit dynamo-browse immediately, without prompting to apply
any changes.
`,
}
func (sc StandardCommands) cmdQuit(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, tea.Quit)
return nil, nil
}
var cmdTableDoc = repl.Doc{
Brief: "Prompt for table to scan",
Usage: "[NAME]",
Args: []repl.ArgDoc{
{Name: "name", Brief: "Name of the table to scan"},
},
Detailed: `
If called with an argument, it will scan the table with that name and
replace the current result set. If called without an argument, it will
prompt for a table to scan.
This command is intended only for interactive sessions and is not suitable
for scripting. The scan or table prompts will happen asynchronously.
`,
}
func (sc StandardCommands) cmdTable(ctx context.Context, args ucl.CallArgs) (any, error) {
var tableName string
if err := args.Bind(&tableName); err == nil {
commandctrl.PostMsg(ctx, sc.ReadController.ScanTable(tableName))
return nil, nil
}
commandctrl.PostMsg(ctx, sc.ReadController.ListTables(false))
return nil, nil
}
var cmdExportDoc = repl.Doc{
Brief: "Exports a result-set as CSV",
Usage: "FILENAME [-all]",
Args: []repl.ArgDoc{
{Name: "filename", Brief: "Filename to export to"},
{Name: "-all", Brief: "Export all results from the table"},
},
Detailed: `
The fields of the current table view will be treated as the header of the
exported table.
This command is intended only for interactive sessions and is not suitable
for scripting. The export will run asynchronously.
`,
}
func (sc StandardCommands) cmdExport(ctx context.Context, args ucl.CallArgs) (any, error) {
var filename string
if err := args.Bind(&filename); err != nil {
return nil, errors.New("expected filename")
}
opts := controllers.ExportOptions{
AllResults: args.HasSwitch("all"),
}
commandctrl.PostMsg(ctx, sc.ExportController.ExportCSV(filename, opts))
return nil, nil
}
var cmdMarkDoc = repl.Doc{
Brief: "Set the marked items of the current result-set",
Usage: "[WHAT] [-where EXPR]",
Args: []repl.ArgDoc{
{Name: "what", Brief: "Items to mark. Defaults to 'all'"},
{Name: "-where", Brief: "Filter expression select items to mark"},
},
Detailed: `
WHAT can be one of:
- all: Mark all items in the current result-set
- none: Unmark all items in the current result-set
- toggle: Toggle the marked state of all items in the current result-set
`,
}
func (sc StandardCommands) cmdMark(ctx context.Context, args ucl.CallArgs) (any, error) {
var markOp = controllers.MarkOpMark
if args.NArgs() > 0 {
var markOpStr string
if err := args.Bind(&markOpStr); err == nil {
switch markOpStr {
case "all":
markOp = controllers.MarkOpMark
case "none":
markOp = controllers.MarkOpUnmark
case "toggle":
markOp = controllers.MarkOpToggle
default:
return nil, errors.New("unrecognised mark operation")
}
}
}
var whereExpr = ""
if args.HasSwitch("where") {
if err := args.BindSwitch("where", &whereExpr); err != nil {
return nil, err
}
}
commandctrl.PostMsg(ctx, sc.ReadController.Mark(markOp, whereExpr))
return nil, nil
}
var cmdNextPageDoc = repl.Doc{
Brief: "Retrieve and display the next page of the current result-set",
Detailed: `
This command is intended only for interactive sessions and is not suitable
for scripting. Fetching the next page will run asynchronously.
`,
}
func (sc StandardCommands) cmdNextPage(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, sc.ReadController.NextPage())
return nil, nil
}
var cmdDeleteDoc = repl.Doc{
Brief: "Delete the marked items of the current result-set",
Detailed: `
The user will be prompted to confirm the deletion. If approved, the
items will be deleted immediately.
This command is intended only for interactive sessions and is not suitable
for scripting.
`,
}
func (sc StandardCommands) cmdDelete(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, sc.WriteController.DeleteMarked())
return nil, nil
}
var cmdNewItemDoc = repl.Doc{
Brief: "Adds a new item to the current result-set",
Detailed: `
The user will be prompted to enter the values for each required attribute,
such as the partition and sort key. The new item will be commited to the database
upon the next write.
This command is intended only for interactive sessions and is not suitable
for scripting.
`,
}
func (sc StandardCommands) cmdNewItem(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, sc.WriteController.NewItem())
return nil, nil
}
var cmdCloneDoc = repl.Doc{
Brief: "Adds a copy of the selected item as a new item to the current result-set",
Detailed: `
The user will be prompted to enter the partition and sort key. All other
attributes will be cloned from the selected item. The new item will be
commited to the database upon the next write.
This command is intended only for interactive sessions and is not suitable
for scripting.
`,
}
func (sc StandardCommands) cmdClone(ctx context.Context, args ucl.CallArgs) (any, error) {
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
commandctrl.PostMsg(ctx, sc.WriteController.CloneItem(selectedItemIndex))
return nil, nil
}
var cmdSetAttrDoc = repl.Doc{
Brief: "Modify a field value of the selected or marked items",
Usage: "ATTR [TYPE]",
Args: []repl.ArgDoc{
{Name: "attr", Brief: "Attribute to modify"},
{Name: "-S", Brief: "Set attribute to a string"},
{Name: "-N", Brief: "Set attribute to a number"},
{Name: "-BOOL", Brief: "Set attribute to a boolean"},
{Name: "-NULL", Brief: "Set attribute to a null"},
{Name: "-TO", Brief: "Set attribute to the result of an expression"},
},
Detailed: `
The user will be prompted to enter the new value for the attribute.
If the attribute type is not known, then a type will need to be specified.
Otherwise, the type will be unchanged. The modified item will be
commited to the database upon the next write.
`,
}
func (sc StandardCommands) cmdSetAttr(ctx context.Context, args ucl.CallArgs) (any, error) {
var fieldName string
if err := args.Bind(&fieldName); err != nil {
return nil, errors.New("expected field name")
}
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
var itemType = models.UnsetItemType
switch {
case args.HasSwitch("S"):
itemType = models.StringItemType
case args.HasSwitch("N"):
itemType = models.NumberItemType
case args.HasSwitch("BOOL"):
itemType = models.BoolItemType
case args.HasSwitch("NULL"):
itemType = models.NullItemType
case args.HasSwitch("TO"):
itemType = models.ExprValueItemType
}
commandctrl.PostMsg(ctx, sc.WriteController.SetAttributeValue(selectedItemIndex, itemType, fieldName))
return nil, nil
}
var cmdDelAttrDoc = repl.Doc{
Brief: "Remove the field of the selected or marked items",
Usage: "ATTR",
Args: []repl.ArgDoc{
{Name: "attr", Brief: "Attribute to remove"},
},
Detailed: `
The modified item will be commited to the database upon the next write.
`,
}
func (sc StandardCommands) cmdDelAttr(ctx context.Context, args ucl.CallArgs) (any, error) {
var fieldName string
if err := args.Bind(&fieldName); err != nil {
return nil, errors.New("expected field name")
}
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
commandctrl.PostMsg(ctx, sc.WriteController.DeleteAttribute(selectedItemIndex, fieldName))
return nil, nil
}
var cmdPutDoc = repl.Doc{
Brief: "Commit changes to the table",
Detailed: `
This will put all new and modified items.
This command is intended only for interactive sessions and is not suitable
for scripting. The user will be prompted to confirm the changes.
`,
}
func (sc StandardCommands) cmdPut(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, sc.WriteController.PutItems())
return nil, nil
}
var cmdTouchDoc = repl.Doc{
Brief: "Put the currently selected item",
Detailed: `
This will put the currently selected item, regardless of whether it has been
modified.
This command is intended only for interactive sessions and is not suitable
for scripting. The user will be prompted to confirm the touch.
`,
}
func (sc StandardCommands) cmdTouch(ctx context.Context, args ucl.CallArgs) (any, error) {
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
commandctrl.PostMsg(ctx, sc.WriteController.TouchItem(selectedItemIndex))
return nil, nil
}
var cmdNoisyTouchDoc = repl.Doc{
Brief: "Put the currently selected item by deleting it first",
Detailed: `
This will put the currently selected item, regardless of whether it has been
modified. It does so by removing the item from the table, then adding it back again.
This command is intended only for interactive sessions and is not suitable
for scripting. The user will be prompted to confirm the touch.
`,
}
func (sc StandardCommands) cmdNoisyTouch(ctx context.Context, args ucl.CallArgs) (any, error) {
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
commandctrl.PostMsg(ctx, sc.WriteController.NoisyTouchItem(selectedItemIndex))
return nil, nil
}
var cmdRebindDoc = repl.Doc{
Brief: "Binds a new key to an action",
Usage: "ACTION KEY",
Args: []repl.ArgDoc{
{Name: "action", Brief: "Action to bind"},
{Name: "key", Brief: "Key to bind"},
},
Detailed: `
If the key is already bound to an action, it will be replaced.
The set of actions this command accepts is well-defined. For binding
to arbitrary actions, use the ui:bind command.
`,
}
func (sc StandardCommands) cmdRebind(ctx context.Context, args ucl.CallArgs) (any, error) {
var bindingName, newKey string
if err := args.Bind(&bindingName, &newKey); err != nil {
return nil, errors.New("expected: bindingName newKey")
}
// TODO: should only force if not interactive
commandctrl.PostMsg(ctx, sc.KeyBindingController.Rebind(bindingName, newKey, false))
return nil, nil
}
func (sc StandardCommands) InstOptions() []ucl.InstOption {
return []ucl.InstOption{
ucl.WithModule(moduleRS(sc.TableService, sc.State)),
ucl.WithModule(sc.modUI),
ucl.WithModule(modulePB(sc.PBProvider)),
}
}
func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) {
ucl.SetBuiltin("quit", sc.cmdQuit)
ucl.SetBuiltin("table", sc.cmdTable)
ucl.SetBuiltin("export", sc.cmdExport)
ucl.SetBuiltin("mark", sc.cmdMark)
// unmark --> alias for { mark none }
ucl.SetBuiltin("next-page", sc.cmdNextPage)
ucl.SetBuiltin("delete", sc.cmdDelete)
ucl.SetBuiltin("new-item", sc.cmdNewItem)
ucl.SetBuiltin("clone", sc.cmdClone)
ucl.SetBuiltin("set-attr", sc.cmdSetAttr)
ucl.SetBuiltin("del-attr", sc.cmdDelAttr)
ucl.SetBuiltin("put", sc.cmdPut)
ucl.SetBuiltin("touch", sc.cmdTouch)
ucl.SetBuiltin("noisy-touch", sc.cmdNoisyTouch)
ucl.SetBuiltin("rebind", sc.cmdRebind)
// set-opt --> alias to opts:set
ucl.SetPseudoVar("resultset", resultSetPVar{sc.State, sc.ReadController})
ucl.SetPseudoVar("table", tablePVar{sc.State})
ucl.SetPseudoVar("item", itemPVar{sc.State})
}

View file

@ -0,0 +1,211 @@
package cmdpacks_test
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/dynamo"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/inputhistorystore"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/settingstore"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/workspacestore"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/inputhistory"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs"
keybindings_service "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/viewsnapshot"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings"
"github.com/lmika/dynamo-browse/test/testdynamo"
"github.com/lmika/dynamo-browse/test/testworkspace"
bus "github.com/lmika/events"
"github.com/stretchr/testify/assert"
"testing"
)
func TestStdCmds_Mark(t *testing.T) {
tests := []struct {
descr string
cmd string
wantMarks []bool
}{
{descr: "mark default", cmd: "mark", wantMarks: []bool{true, true, true}},
{descr: "mark all", cmd: "mark all", wantMarks: []bool{true, true, true}},
{descr: "mark none", cmd: "mark none", wantMarks: []bool{false, false, false}},
{descr: "mark where", cmd: `mark -where 'sk="222"'`, wantMarks: []bool{false, true, false}},
{descr: "mark toggle", cmd: "mark ; mark toggle", wantMarks: []bool{false, false, false}},
}
for _, tt := range tests {
t.Run(tt.descr, func(t *testing.T) {
svc := newService(t)
_, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd)
assert.NoError(t, err)
for i, want := range tt.wantMarks {
assert.Equal(t, want, svc.State.ResultSet().Marked(i))
}
})
}
}
type testDataGenerator func() []testdynamo.TestData
type services struct {
CommandController *commandctrl.CommandController
SelItemIndex int
State *controllers.State
settingStore *settingstore.SettingStore
table string
testDataGenerator testDataGenerator
testData []testdynamo.TestData
}
type serviceOpt func(*services)
func withDataGenerator(tg testDataGenerator) serviceOpt {
return func(s *services) {
s.testDataGenerator = tg
}
}
func withTable(table string) serviceOpt {
return func(s *services) {
s.table = table
}
}
func withDefaultLimit(limit int) serviceOpt {
return func(s *services) {
s.settingStore.SetDefaultLimit(limit)
}
}
func newService(t *testing.T, opts ...serviceOpt) *services {
ws := testworkspace.New(t)
resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws)
settingStore := settingstore.New(ws)
inputHistoryStore := inputhistorystore.NewInputHistoryStore(ws)
workspaceService := viewsnapshot.NewService(resultSetSnapshotStore)
itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer())
inputHistoryService := inputhistory.New(inputHistoryStore)
s := &services{
table: "service-test-data",
settingStore: settingStore,
testDataGenerator: normalTestData,
}
for _, opt := range opts {
opt(s)
}
s.testData = s.testDataGenerator()
client := testdynamo.SetupTestTable(t, s.testData)
provider := dynamo.NewProvider(client)
service := tables.NewService(provider, settingStore)
eventBus := bus.New()
state := controllers.NewState()
jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true)
readController := controllers.NewTableReadController(
state,
service,
workspaceService,
itemRendererService,
jobsController,
inputHistoryService,
eventBus,
pasteboardprovider.NilProvider{},
nil,
s.table,
)
writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore)
settingsController := controllers.NewSettingsController(settingStore, eventBus)
columnsController := controllers.NewColumnsController(readController, eventBus)
exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{})
keyBindingService := keybindings_service.NewService(keybindings.Default())
keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil)
_ = settingsController
commandController := commandctrl.NewCommandController(inputHistoryService,
cmdpacks.NewStandardCommands(service, state, readController, writeController, exportController, keyBindingController),
)
s.State = state
s.CommandController = commandController
commandController.SetUIStateProvider(s)
readController.Init()
return s
}
func (s *services) SelectedItemIndex() int {
return s.SelItemIndex
}
func (s *services) SetSelectedItemIndex(newIdx int) tea.Msg {
s.SelItemIndex = newIdx
return nil
}
func normalTestData() []testdynamo.TestData {
return []testdynamo.TestData{
{
TableName: "service-test-data",
Data: []map[string]interface{}{
{
"pk": "abc",
"sk": "111",
"alpha": "This is some value",
},
{
"pk": "abc",
"sk": "222",
"alpha": "This is another some value",
"beta": 1231,
},
{
"pk": "bbb",
"sk": "131",
"beta": 2468,
"gamma": "foobar",
},
},
},
}
}
func largeTestData() []testdynamo.TestData {
return []testdynamo.TestData{
{
TableName: "large-table",
Data: genRow(50, func(i int) map[string]interface{} {
return map[string]interface{}{
"pk": fmt.Sprint(i),
"sk": fmt.Sprint(i),
"alpha": fmt.Sprintf("row %v", i),
}
}),
},
}
}
func genRow(count int, mapFn func(int) map[string]interface{}) []map[string]interface{} {
result := make([]map[string]interface{}, count)
for i := 0; i < count; i++ {
result[i] = mapFn(i)
}
return result
}

View file

@ -1,9 +1,9 @@
package commandctrl
import (
"bufio"
"bytes"
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/pkg/errors"
"log"
@ -11,6 +11,7 @@ import (
"path/filepath"
"strings"
"ucl.lmika.dev/ucl"
"ucl.lmika.dev/ucl/builtins"
"github.com/lmika/dynamo-browse/internal/common/ui/events"
"github.com/lmika/shellwords"
@ -18,25 +19,49 @@ import (
const commandsCategory = "commands"
type cmdMessage struct {
cmd string
}
type CommandController struct {
uclInst *ucl.Inst
historyProvider IterProvider
commandList *CommandList
msgSender func(tea.Msg)
lookupExtensions []CommandLookupExtension
completionProvider CommandCompletionProvider
uiStateProvider UIStateProvider
cmdChan chan cmdMessage
msgChan chan tea.Msg
interactive bool
}
func NewCommandController(historyProvider IterProvider) *CommandController {
func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) *CommandController {
cc := &CommandController{
historyProvider: historyProvider,
commandList: nil,
lookupExtensions: nil,
cmdChan: make(chan cmdMessage),
msgChan: make(chan tea.Msg),
interactive: true,
}
cc.uclInst = ucl.New(
options := []ucl.InstOption{
ucl.WithOut(ucl.LineHandler(cc.printLine)),
ucl.WithMissingBuiltinHandler(cc.cmdInvoker),
)
ucl.WithModule(builtins.OS()),
ucl.WithModule(builtins.FS(nil)),
}
for _, pkg := range pkgs {
options = append(options, pkg.InstOptions()...)
}
cc.uclInst = ucl.New(options...)
for _, pkg := range pkgs {
pkg.ConfigureUCL(cc.uclInst)
}
go cc.cmdLooper()
return cc
}
@ -45,8 +70,14 @@ func (c *CommandController) AddCommands(ctx *CommandList) {
c.commandList = ctx
}
func (c *CommandController) SetMessageSender(msg func(tea.Msg)) {
c.msgSender = msg
func (c *CommandController) StartMessageSender(msgSender func(tea.Msg)) {
for msg := range c.msgChan {
msgSender(msg)
}
}
func (c *CommandController) SetUIStateProvider(provider UIStateProvider) {
c.uiStateProvider = provider
}
func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension) {
@ -95,17 +126,57 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms
return nil
}
res, err := c.uclInst.Eval(context.Background(), commandInput)
if err != nil {
return events.Error(err)
select {
case c.cmdChan <- cmdMessage{cmd: input}:
// good
default:
return events.Error(errors.New("command currently running"))
}
if teaMsg, ok := res.(teaMsgWrapper); ok {
return teaMsg.msg
}
return nil
}
func (c *CommandController) ExecuteAndWait(ctx context.Context, commandInput string) (any, error) {
return c.uclInst.EvalString(ctx, commandInput)
}
func (c *CommandController) Invoke(invokable ucl.Invokable, args []any) (msg tea.Msg) {
execCtx := execContext{ctrl: c}
ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx)
res, err := invokable.Invoke(ctx, args)
if err != nil {
msg = events.Error(err)
} else if res != nil {
msg = events.StatusMsg(fmt.Sprint(res))
}
if execCtx.requestRefresh {
c.postMessage(events.ResultSetUpdated{})
}
return msg
}
func (c *CommandController) cmdLooper() {
execCtx := execContext{ctrl: c}
ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx)
for {
select {
case cmdChan := <-c.cmdChan:
res, err := c.ExecuteAndWait(ctx, cmdChan.cmd)
if err != nil {
c.postMessage(events.Error(err))
} else if res != nil {
c.postMessage(events.StatusMsg(fmt.Sprint(res)))
}
if execCtx.requestRefresh {
c.postMessage(events.ResultSetUpdated{})
}
}
}
}
func (c *CommandController) Alias(commandName string) Command {
return func(ctx ExecContext, args ucl.CallArgs) tea.Msg {
command := c.lookupCommand(commandName)
@ -132,41 +203,78 @@ func (c *CommandController) lookupCommand(name string) Command {
return nil
}
func (c *CommandController) ExecuteFile(filename string) error {
baseFilename := filepath.Base(filename)
func (c *CommandController) LoadExtensions(ctx context.Context, baseDirs []string) error {
log.Printf("loading extensions: %v", baseDirs)
for _, baseDir := range baseDirs {
baseDir = os.ExpandEnv(baseDir)
descendIntoSubDirs := !strings.HasSuffix(baseDir, ".")
if rcFile, err := os.ReadFile(filename); err == nil {
if err := c.executeFile(rcFile, baseFilename); err != nil {
return errors.Wrapf(err, "error executing %v", filename)
if stat, err := os.Stat(baseDir); err != nil {
if os.IsNotExist(err) {
continue
}
return err
} else if !stat.IsDir() {
continue
}
log.Printf("walking %v", baseDir)
if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if !descendIntoSubDirs && path != baseDir {
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(info.Name(), ".ucl") {
if err := c.ExecuteFile(ctx, path); err != nil {
log.Println(err)
}
log.Printf("loaded %v\n", path)
}
return nil
}); err != nil {
return err
}
} else {
return errors.Wrapf(err, "error loading %v", filename)
}
return nil
}
func (c *CommandController) executeFile(file []byte, filename string) error {
scnr := bufio.NewScanner(bytes.NewReader(file))
func (c *CommandController) ExecuteFile(ctx context.Context, filename string) error {
oldInteractive := c.interactive
c.interactive = false
defer func() {
c.interactive = oldInteractive
}()
lineNo := 0
for scnr.Scan() {
lineNo++
line := strings.TrimSpace(scnr.Text())
if line == "" {
continue
} else if line[0] == '#' {
continue
}
baseFilename := filepath.Base(filename)
msg := c.execute(ExecContext{FromFile: true}, line)
switch m := msg.(type) {
case events.ErrorMsg:
log.Printf("%v:%v: error - %v", filename, lineNo, m.Error())
case events.StatusMsg:
log.Printf("%v:%v: %v", filename, lineNo, string(m))
execCtx := execContext{ctrl: c}
ctx = context.WithValue(context.Background(), commandCtlKey, &execCtx)
if rcFile, err := os.ReadFile(filename); err == nil {
if err := c.executeFile(ctx, rcFile); err != nil {
return errors.Wrapf(err, "error executing %v", baseFilename)
}
} else {
return errors.Wrapf(err, "error loading %v", baseFilename)
}
return scnr.Err()
return nil
}
func (c *CommandController) executeFile(ctx context.Context, file []byte) error {
if _, err := c.uclInst.Eval(ctx, bytes.NewReader(file), ucl.WithSubEnv()); err != nil {
return err
}
return nil
}
func (c *CommandController) Inst() *ucl.Inst {
return c.uclInst
}
func (c *CommandController) cmdInvoker(ctx context.Context, name string, args ucl.CallArgs) (any, error) {
@ -183,9 +291,24 @@ func (c *CommandController) cmdInvoker(ctx context.Context, name string, args uc
}
func (c *CommandController) printLine(s string) {
if c.msgSender != nil {
c.msgSender(events.StatusMsg(s))
if c.msgChan == nil || !c.interactive {
log.Println(s)
return
}
select {
case c.msgChan <- events.StatusMsg(s):
default:
log.Println(s)
}
}
func (c *CommandController) postMessage(msg tea.Msg) {
if c.msgChan == nil {
return
}
c.msgChan <- msg
}
type teaMsgWrapper struct {

View file

@ -0,0 +1,63 @@
package commandctrl
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"ucl.lmika.dev/ucl"
)
type commandCtlKeyType struct{}
var commandCtlKey = commandCtlKeyType{}
type execContext struct {
ctrl *CommandController
requestRefresh bool
}
func PostMsg(ctx context.Context, msg tea.Msg) {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if ok {
cmdCtl.ctrl.postMessage(msg)
}
}
func SelectedItemIndex(ctx context.Context) (int, bool) {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return 0, false
}
return cmdCtl.ctrl.uiStateProvider.SelectedItemIndex(), true
}
func SetSelectedItemIndex(ctx context.Context, newIdx int) tea.Msg {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return nil
}
return cmdCtl.ctrl.uiStateProvider.SetSelectedItemIndex(newIdx)
}
func GetInvoker(ctx context.Context) Invoker {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return nil
}
return cmdCtl.ctrl
}
func QueueRefresh(ctx context.Context) {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return
}
cmdCtl.requestRefresh = true
}
type Invoker interface {
Invoke(invokable ucl.Invokable, args []any) tea.Msg
Inst() *ucl.Inst
}

View file

@ -2,9 +2,15 @@ package commandctrl
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services"
)
type IterProvider interface {
Iter(ctx context.Context, category string) services.HistoryProvider
}
type UIStateProvider interface {
SelectedItemIndex() int
SetSelectedItemIndex(newIdx int) tea.Msg
}

View file

@ -0,0 +1,8 @@
package commandctrl
import "ucl.lmika.dev/ucl"
type CommandPack interface {
InstOptions() []ucl.InstOption
ConfigureUCL(ucl *ucl.Inst)
}

View file

@ -0,0 +1,5 @@
package events
type ResultSetUpdated struct {
StatusMessage string
}

View file

@ -81,14 +81,6 @@ type PromptForTableMsg struct {
OnSelected func(tableName string) tea.Msg
}
type ResultSetUpdated struct {
statusMessage string
}
func (rs ResultSetUpdated) StatusMessage() string {
return rs.statusMessage
}
type ShowColumnOverlay struct{}
type HideColumnOverlay struct{}

View file

@ -107,7 +107,7 @@ func (c *ExportController) ExportCSVToClipboard() tea.Msg {
if err := c.pasteboardProvider.WriteText(bts.Bytes()); err != nil {
return events.Error(err)
}
return nil
return events.StatusMsg("Table copied to clipboard")
}
// TODO: this really needs to be a service!

View file

@ -20,6 +20,10 @@ func NewKeyBindingController(service *keybindings.Service, customBindingSource C
}
}
func (kb *KeyBindingController) SetCustomKeyBindingSource(customBindingSource CustomKeyBindingSource) {
kb.customBindingSource = customBindingSource
}
func (kb *KeyBindingController) Rebind(bindingName string, newKey string, force bool) tea.Msg {
existingBinding := kb.findExistingBinding(newKey)
if existingBinding == "" {

View file

@ -29,6 +29,14 @@ func (s *State) Filter() string {
return s.filter
}
func (s *State) SetResultSet(resultSet *models.ResultSet) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.resultSet = resultSet
s.filter = ""
}
func (s *State) withResultSet(rs func(*models.ResultSet)) {
s.mutex.Lock()
defer s.mutex.Unlock()

View file

@ -182,6 +182,10 @@ func (c *TableReadController) PromptForQuery() tea.Msg {
}
}
func (c *TableReadController) RunQuery(q *queryexpr.QueryExpr, table *models.TableInfo) tea.Msg {
return c.runQuery(table, q, "", true, nil)
}
func (c *TableReadController) runQuery(
tableInfo *models.TableInfo,
query *queryexpr.QueryExpr,
@ -291,6 +295,12 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet,
return c.state.buildNewResultSetMessage("")
}
func (c *TableReadController) SetResultSet(resultSet *models.ResultSet) tea.Msg {
c.state.setResultSetAndFilter(resultSet, "")
c.eventBus.Fire(newResultSetEvent, resultSet, resultSetUpdateScript)
return c.state.buildNewResultSetMessage("")
}
func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg {
var (
whereExpr *queryexpr.QueryExpr
@ -333,27 +343,31 @@ func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg {
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
}
func (c *TableReadController) Filter() tea.Msg {
func (c *TableReadController) PromptForFilter() tea.Msg {
return events.PromptForInputMsg{
Prompt: "filter: ",
History: c.inputHistoryService.Iter(context.Background(), filterInputHistoryCategory),
OnDone: func(value string) tea.Msg {
resultSet := c.state.ResultSet()
if resultSet == nil {
return events.StatusMsg("Result-set is nil")
}
return NewJob(c.jobController, "Applying Filter…", func(ctx context.Context) (*models.ResultSet, error) {
newResultSet := c.tableService.Filter(resultSet, value)
return newResultSet, nil
}).OnEither(c.handleResultSetFromJobResult(value, true, false, resultSetUpdateFilter)).Submit()
return c.Filter(value)
},
}
}
func (c *TableReadController) Filter(value string) tea.Msg {
resultSet := c.state.ResultSet()
if resultSet == nil {
return events.StatusMsg("Result-set is nil")
}
return NewJob(c.jobController, "Applying Filter…", func(ctx context.Context) (*models.ResultSet, error) {
newResultSet := c.tableService.Filter(resultSet, value)
return newResultSet, nil
}).OnEither(c.handleResultSetFromJobResult(value, true, false, resultSetUpdateFilter)).Submit()
}
func (c *TableReadController) handleResultSetFromJobResult(
filter string,
pushbackStack, errIfEmpty bool,

View file

@ -44,7 +44,7 @@ func (twc *TableWriteController) ToggleMark(idx int) tea.Msg {
resultSet.SetMark(idx, !resultSet.Marked(idx))
})
return ResultSetUpdated{}
return events.ResultSetUpdated{}
}
func (twc *TableWriteController) NewItem() tea.Msg {
@ -148,7 +148,7 @@ func (twc *TableWriteController) setStringValue(idx int, attr *queryexpr.QueryEx
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}
@ -181,7 +181,7 @@ func (twc *TableWriteController) setToExpressionValue(idx int, attr *queryexpr.Q
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}
@ -205,7 +205,7 @@ func (twc *TableWriteController) setNumberValue(idx int, attr *queryexpr.QueryEx
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}
@ -234,7 +234,7 @@ func (twc *TableWriteController) setBoolValue(idx int, attr *queryexpr.QueryExpr
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}
@ -255,7 +255,7 @@ func (twc *TableWriteController) setNullValue(idx int, attr *queryexpr.QueryExpr
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
}
func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg {
@ -291,7 +291,7 @@ func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
}
func (twc *TableWriteController) PutItems() tea.Msg {
@ -351,8 +351,8 @@ func (twc *TableWriteController) PutItems() tea.Msg {
}
return rs, nil
}).OnDone(func(rs *models.ResultSet) tea.Msg {
return ResultSetUpdated{
statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"),
return events.ResultSetUpdated{
StatusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"),
}
}).Submit()
},
@ -379,7 +379,7 @@ func (twc *TableWriteController) TouchItem(idx int) tea.Msg {
if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}

View file

@ -151,3 +151,37 @@ func (rs *ResultSet) Sort(criteria SortCriteria) {
rs.sortCriteria = criteria
Sort(rs.items, criteria)
}
func (rs *ResultSet) MergeWith(otherRS *ResultSet) *ResultSet {
type pksk struct {
pk types.AttributeValue
sk types.AttributeValue
}
if !rs.TableInfo.Equal(otherRS.TableInfo) {
return nil
}
itemsInI := make(map[pksk]Item)
newItems := make([]Item, 0, len(rs.Items())+len(otherRS.Items()))
for _, item := range rs.Items() {
pk, sk := item.PKSK(rs.TableInfo)
itemsInI[pksk{pk, sk}] = item
newItems = append(newItems, item)
}
for _, item := range otherRS.Items() {
pk, sk := item.PKSK(rs.TableInfo)
if _, hasItem := itemsInI[pksk{pk, sk}]; !hasItem {
newItems = append(newItems, item)
}
}
newResultSet := &ResultSet{
Created: time.Now(),
TableInfo: rs.TableInfo,
}
newResultSet.SetItems(newItems)
return newResultSet
}

View file

@ -8,6 +8,7 @@ import (
"github.com/pkg/errors"
"math/big"
"strconv"
"strings"
)
type exprValue interface {
@ -62,6 +63,14 @@ func newExprValueFromAttributeValue(ev types.AttributeValue) (exprValue, error)
case *types.AttributeValueMemberS:
return stringExprValue(xVal.Value), nil
case *types.AttributeValueMemberN:
if !strings.Contains(xVal.Value, ".") {
iVal, err := strconv.ParseInt(xVal.Value, 10, 64)
if err != nil {
return nil, err
}
return int64ExprValue(iVal), nil
}
xNumVal, _, err := big.ParseFloat(xVal.Value, 10, 63, big.ToNearestEven)
if err != nil {
return nil, err
@ -139,6 +148,32 @@ func (s int64ExprValue) typeName() string {
return "N"
}
type bigIntExprValue struct {
num *big.Int
}
func (i bigIntExprValue) asGoValue() any {
return i.num
}
func (i bigIntExprValue) asAttributeValue() types.AttributeValue {
return &types.AttributeValueMemberN{Value: i.num.String()}
}
func (i bigIntExprValue) asInt() int64 {
return i.num.Int64()
}
func (i bigIntExprValue) asBigFloat() *big.Float {
var f big.Float
f.SetInt64(i.num.Int64())
return &f
}
func (s bigIntExprValue) typeName() string {
return "N"
}
type bigNumExprValue struct {
num *big.Float
}

View file

@ -1,16 +1,11 @@
package ui
import (
"log"
"os"
"ucl.lmika.dev/ucl"
"github.com/charmbracelet/bubbles/key"
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/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings"
@ -26,7 +21,7 @@ import (
"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"
)
const (
@ -37,8 +32,6 @@ const (
ViewModeTableOnly = 4
ViewModeCount = 5
initRCFilename = "$HOME/.config/audax/dynamo-browse/init.rc"
)
type Model struct {
@ -47,14 +40,14 @@ type Model struct {
settingsController *controllers.SettingsController
exportController *controllers.ExportController
commandController *commandctrl.CommandController
scriptController *controllers.ScriptController
jobController *controllers.JobsController
colSelector *colselector.Model
relSelector *relselector.Model
itemEdit *dynamoitemedit.Model
statusAndPrompt *statusandprompt.StatusAndPrompt
tableSelect *tableselect.Model
eventBus *bus.Bus
//scriptController *controllers.ScriptController
jobController *controllers.JobsController
colSelector *colselector.Model
relSelector *relselector.Model
itemEdit *dynamoitemedit.Model
statusAndPrompt *statusandprompt.StatusAndPrompt
tableSelect *tableselect.Model
eventBus *bus.Bus
mainViewIndex int
@ -75,7 +68,7 @@ func NewModel(
jobController *controllers.JobsController,
itemRendererService *itemrenderer.Service,
cc *commandctrl.CommandController,
scriptController *controllers.ScriptController,
//scriptController *controllers.ScriptController,
eventBus *bus.Bus,
keyBindingController *controllers.KeyBindingController,
pasteboardProvider services.PasteboardProvider,
@ -94,164 +87,164 @@ func NewModel(
dialogPrompt := dialogprompt.New(statusAndPrompt)
tableSelect := tableselect.New(dialogPrompt, uiStyles)
cc.AddCommands(&commandctrl.CommandList{
Commands: map[string]commandctrl.Command{
"quit": commandctrl.NoArgCommand(tea.Quit),
"table": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var tableName string
if err := args.Bind(&tableName); err == nil {
return rc.ScanTable(tableName)
}
return rc.ListTables(false)
},
"export": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var filename string
if err := args.Bind(&filename); err != nil {
return events.Error(errors.New("expected filename"))
}
opts := controllers.ExportOptions{
AllResults: args.HasSwitch("all"),
}
return exportController.ExportCSV(filename, opts)
},
"mark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var markOp = controllers.MarkOpMark
var markOpStr string
if err := args.Bind(&markOpStr); err == nil {
switch markOpStr {
case "all":
markOp = controllers.MarkOpMark
case "none":
markOp = controllers.MarkOpUnmark
case "toggle":
markOp = controllers.MarkOpToggle
default:
return events.Error(errors.New("unrecognised mark operation"))
/*
cc.AddCommands(&commandctrl.CommandList{
Commands: map[string]commandctrl.Command{
"quit": commandctrl.NoArgCommand(tea.Quit),
"table": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var tableName string
if err := args.Bind(&tableName); err == nil {
return rc.ScanTable(tableName)
}
}
var whereExpr = ""
_ = args.BindSwitch("where", &whereExpr)
return rc.Mark(markOp, whereExpr)
},
"unmark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return rc.Mark(controllers.MarkOpUnmark, "")
},
"next-page": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return rc.NextPage()
},
"delete": commandctrl.NoArgCommand(wc.DeleteMarked),
// TEMP
"new-item": commandctrl.NoArgCommand(wc.NewItem),
"clone": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return wc.CloneItem(dtv.SelectedItemIndex())
},
"set-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var fieldName string
if err := args.Bind(&fieldName); err != nil {
return events.Error(errors.New("expected field"))
}
var itemType = models.UnsetItemType
switch {
case args.HasSwitch("S"):
itemType = models.StringItemType
case args.HasSwitch("N"):
itemType = models.NumberItemType
case args.HasSwitch("BOOL"):
itemType = models.BoolItemType
case args.HasSwitch("NULL"):
itemType = models.NullItemType
case args.HasSwitch("TO"):
itemType = models.ExprValueItemType
default:
return events.Error(errors.New("unrecognised item type"))
}
return wc.SetAttributeValue(dtv.SelectedItemIndex(), itemType, fieldName)
},
"del-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var fieldName string
// TODO: support rest args
if err := args.Bind(&fieldName); err != nil {
return events.Error(errors.New("expected field"))
}
return wc.DeleteAttribute(dtv.SelectedItemIndex(), fieldName)
},
"put": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return wc.PutItems()
},
"touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return wc.TouchItem(dtv.SelectedItemIndex())
},
"noisy-touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return wc.NoisyTouchItem(dtv.SelectedItemIndex())
},
/*
"echo": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
s := new(strings.Builder)
for _, arg := range args {
s.WriteString(arg)
}
return events.StatusMsg(s.String())
return rc.ListTables(false)
},
*/
"set-opt": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var name string
if err := args.Bind(&name); err != nil {
return events.Error(errors.New("expected settingName"))
}
"export": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var filename string
if err := args.Bind(&filename); err != nil {
return events.Error(errors.New("expected filename"))
}
var value string
if err := args.Bind(&value); err == nil {
return settingsController.SetSetting(name, value)
}
opts := controllers.ExportOptions{
AllResults: args.HasSwitch("all"),
}
return settingsController.SetSetting(name, "")
return exportController.ExportCSV(filename, opts)
},
"mark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var markOp = controllers.MarkOpMark
var markOpStr string
if err := args.Bind(&markOpStr); err == nil {
switch markOpStr {
case "all":
markOp = controllers.MarkOpMark
case "none":
markOp = controllers.MarkOpUnmark
case "toggle":
markOp = controllers.MarkOpToggle
default:
return events.Error(errors.New("unrecognised mark operation"))
}
}
var whereExpr = ""
_ = args.BindSwitch("where", &whereExpr)
return rc.Mark(markOp, whereExpr)
},
"unmark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return rc.Mark(controllers.MarkOpUnmark, "")
},
"next-page": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return rc.NextPage()
},
"delete": commandctrl.NoArgCommand(wc.DeleteMarked),
// TEMP
"new-item": commandctrl.NoArgCommand(wc.NewItem),
"clone": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return wc.CloneItem(dtv.SelectedItemIndex())
},
"set-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var fieldName string
if err := args.Bind(&fieldName); err != nil {
return events.Error(errors.New("expected field"))
}
var itemType = models.UnsetItemType
switch {
case args.HasSwitch("S"):
itemType = models.StringItemType
case args.HasSwitch("N"):
itemType = models.NumberItemType
case args.HasSwitch("BOOL"):
itemType = models.BoolItemType
case args.HasSwitch("NULL"):
itemType = models.NullItemType
case args.HasSwitch("TO"):
itemType = models.ExprValueItemType
}
return wc.SetAttributeValue(dtv.SelectedItemIndex(), itemType, fieldName)
},
"del-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var fieldName string
// TODO: support rest args
if err := args.Bind(&fieldName); err != nil {
return events.Error(errors.New("expected field"))
}
return wc.DeleteAttribute(dtv.SelectedItemIndex(), fieldName)
},
"put": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return wc.PutItems()
},
"touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return wc.TouchItem(dtv.SelectedItemIndex())
},
"noisy-touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
return wc.NoisyTouchItem(dtv.SelectedItemIndex())
},
"echo": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
s := new(strings.Builder)
for _, arg := range args {
s.WriteString(arg)
}
return events.StatusMsg(s.String())
},
"set-opt": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var name string
if err := args.Bind(&name); err != nil {
return events.Error(errors.New("expected settingName"))
}
var value string
if err := args.Bind(&value); err == nil {
return settingsController.SetSetting(name, value)
}
return settingsController.SetSetting(name, "")
},
"rebind": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var bindingName, newKey string
if err := args.Bind(&bindingName, &newKey); err != nil {
return events.Error(errors.New("expected: bindingName newKey"))
}
return keyBindingController.Rebind(bindingName, newKey, ctx.FromFile)
},
"run-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var name string
if err := args.Bind(&name); err != nil {
return events.Error(errors.New("expected: script name"))
}
return scriptController.RunScript(name)
},
"load-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var name string
if err := args.Bind(&name); err != nil {
return events.Error(errors.New("expected: script name"))
}
return scriptController.LoadScript(name)
},
// Aliases
"sa": cc.Alias("set-attr"),
"da": cc.Alias("del-attr"),
"np": cc.Alias("next-page"),
"w": cc.Alias("put"),
"q": cc.Alias("quit"),
},
"rebind": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var bindingName, newKey string
if err := args.Bind(&bindingName, &newKey); err != nil {
return events.Error(errors.New("expected: bindingName newKey"))
}
})
return keyBindingController.Rebind(bindingName, newKey, ctx.FromFile)
},
"run-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var name string
if err := args.Bind(&name); err != nil {
return events.Error(errors.New("expected: script name"))
}
return scriptController.RunScript(name)
},
"load-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg {
var name string
if err := args.Bind(&name); err != nil {
return events.Error(errors.New("expected: script name"))
}
return scriptController.LoadScript(name)
},
// Aliases
"sa": cc.Alias("set-attr"),
"da": cc.Alias("del-attr"),
"np": cc.Alias("next-page"),
"w": cc.Alias("put"),
"q": cc.Alias("quit"),
},
})
*/
root := layout.FullScreen(tableSelect)
@ -259,7 +252,8 @@ func NewModel(
tableReadController: rc,
tableWriteController: wc,
commandController: cc,
scriptController: scriptController,
//scriptController: scriptController,
exportController: exportController,
jobController: jobController,
itemEdit: itemEdit,
colSelector: colSelector,
@ -280,10 +274,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case controllers.SetTableItemView:
cmd := m.setMainViewIndex(msg.ViewIndex)
return m, cmd
case controllers.ResultSetUpdated:
case events.ResultSetUpdated:
return m, tea.Batch(
m.tableView.Refresh(),
events.SetStatus(msg.StatusMessage()),
events.SetStatus(msg.StatusMessage),
)
case tea.KeyMsg:
// TODO: use modes here
@ -306,7 +300,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keyMap.PromptForQuery):
return m, m.tableReadController.PromptForQuery
case key.Matches(msg, m.keyMap.PromptForFilter):
return m, m.tableReadController.Filter
return m, m.tableReadController.PromptForFilter
case key.Matches(msg, m.keyMap.FetchNextPage):
return m, m.tableReadController.NextPage
case key.Matches(msg, m.keyMap.ViewBack):
@ -322,10 +316,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.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):
@ -348,12 +342,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Model) Init() tea.Cmd {
// TODO: this should probably be moved somewhere else
rcFilename := os.ExpandEnv(initRCFilename)
if err := m.commandController.ExecuteFile(rcFilename); err != nil {
log.Println(err)
}
return tea.Batch(
m.tableReadController.Init,
m.root.Init(),
@ -397,3 +385,11 @@ func (m *Model) promptToQuit() tea.Msg {
return nil
})
}
func (m *Model) SelectedItemIndex() int {
return m.tableView.SelectedItemIndex()
}
func (m *Model) SetSelectedItemIndex(newIdx int) tea.Msg {
return m.tableView.SetSelectedItemIndex(newIdx)
}

View file

@ -208,6 +208,27 @@ func (m *Model) SelectedItemIndex() int {
return selectedItem.itemIndex
}
func (m *Model) SetSelectedItemIndex(newIdx int) tea.Msg {
cursor := m.table.Cursor()
switch {
case newIdx <= 0:
m.table.GoTop()
case newIdx >= len(m.rows)-1:
m.table.GoBottom()
case newIdx < cursor:
delta := cursor - newIdx
for d := 0; d < delta; d++ {
m.table.GoUp()
}
case newIdx > cursor:
delta := newIdx - cursor
for d := 0; d < delta; d++ {
m.table.GoDown()
}
}
return m.postSelectedItemChanged()
}
func (m *Model) selectedItem() (itemTableRow, bool) {
resultSet := m.resultSet

View file

@ -2,6 +2,7 @@ package testdynamo
import (
"context"
"os"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
@ -28,8 +29,13 @@ func SetupTestTable(t *testing.T, testData []TestData) *dynamodb.Client {
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("abc", "123", "")))
assert.NoError(t, err)
testDynamoURL, ok := os.LookupEnv("TEST_DYNAMO_URL")
if !ok {
testDynamoURL = "http://localhost:4566"
}
dynamoClient := dynamodb.NewFromConfig(cfg,
dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:4566")))
dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL(testDynamoURL)))
for _, table := range testData {
tableInput := &dynamodb.CreateTableInput{