Compare commits

...

4 commits

Author SHA1 Message Date
Leon Mika 7718c0a0b8 Fixed prelude
All checks were successful
ci / Build (push) Successful in 3m5s
2025-11-04 14:25:44 +11:00
Leon Mika 08a3c162a2 Added async:query
Some checks failed
ci / Build (push) Has been cancelled
2025-11-04 14:24:19 +11:00
Leon Mika c11560e6cd Added ui:set-item-annotator 2025-11-01 10:39:10 +11:00
Leon Mika a733a47d5c Added annotation type 2025-11-01 09:50:55 +11:00
18 changed files with 494 additions and 52 deletions

View file

@ -177,6 +177,7 @@ func main() {
keyBindingController,
pasteboardProvider,
settingsController,
itemRendererService,
)
commandController, err := commandctrl.NewCommandController(inputHistoryService, stdCommands)

6
go.mod
View file

@ -25,7 +25,7 @@ require (
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70
github.com/muesli/reflow v0.3.0
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
golang.design/x/clipboard v0.6.2
golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a
ucl.lmika.dev v0.1.2
@ -50,9 +50,12 @@ require (
github.com/aymanbagabas/go-osc52 v1.0.3 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-co-op/gocron/v2 v2.17.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@ -63,6 +66,7 @@ require (
github.com/muesli/termenv v0.13.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/sahilm/fuzzy v0.1.0 // indirect
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect

10
go.sum
View file

@ -75,6 +75,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
@ -84,12 +86,16 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o=
github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@ -146,6 +152,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
@ -154,6 +162,8 @@ github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=

View file

@ -0,0 +1,96 @@
package cmdpacks
import (
"context"
"time"
"github.com/go-co-op/gocron/v2"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/controllers"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"ucl.lmika.dev/ucl"
)
type asyncModule struct {
tableService *tables.Service
state *controllers.State
}
func (m asyncModule) asyncDo(ctx context.Context, args ucl.CallArgs) (any, error) {
var block ucl.Invokable
if err := args.Bind(&block); err != nil {
return nil, err
}
return nil, commandctrl.ScheduleTask(ctx, func(ctx context.Context) error {
_, err := block.Invoke(ctx)
return err
})
}
func (m asyncModule) asyncIn(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
duration int
block ucl.Invokable
)
if err := args.Bind(&duration, &block); err != nil {
return nil, err
}
_, err := commandctrl.CronScheduler(ctx).NewJob(
gocron.OneTimeJob(
gocron.OneTimeJobStartDateTime(time.Now().Add(time.Duration(duration)*time.Second)),
),
gocron.NewTask(func(ctx context.Context) {
commandctrl.ScheduleTask(ctx, func(ctx context.Context) error {
_, err := block.Invoke(ctx)
return err
})
}),
gocron.WithContext(ctx),
)
return nil, err
}
func (m asyncModule) asyncQuery(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
block ucl.Invokable
)
args, q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService, 1)
if err != nil {
return nil, err
}
if err := args.Bind(&block); err != nil {
return nil, err
}
return nil, commandctrl.ScheduleAuxTask(ctx, "query: "+q.String(), func(ctx context.Context) error {
newResultSet, err := m.tableService.ScanOrQuery(context.Background(), tableInfo, q, nil)
if err != nil {
return err
}
return commandctrl.ScheduleTask(ctx, func(ctx context.Context) error {
_, err := block.Invoke(ctx, newResultSetProxy(newResultSet))
return err
})
})
}
func moduleAsync(tableService *tables.Service, state *controllers.State) ucl.Module {
m := asyncModule{
state: state,
tableService: tableService,
}
return ucl.Module{
Name: "async",
Builtins: map[string]ucl.BuiltinHandler{
"do": m.asyncDo,
"in": m.asyncIn,
"query": m.asyncQuery,
},
}
}

View file

@ -2,6 +2,7 @@ package cmdpacks
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"ucl.lmika.dev/ucl"
)

View file

@ -2,6 +2,8 @@ package cmdpacks
import (
"context"
"time"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/pkg/errors"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
@ -9,7 +11,6 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"time"
"ucl.lmika.dev/repl"
"ucl.lmika.dev/ucl"
)
@ -71,21 +72,22 @@ func parseQuery(
args ucl.CallArgs,
currentRS *models.ResultSet,
tablesService *tables.Service,
) (*queryexpr.QueryExpr, *models.TableInfo, error) {
extraArgs int,
) (ucl.CallArgs, *queryexpr.QueryExpr, *models.TableInfo, error) {
var expr string
if err := args.Bind(&expr); err != nil {
return nil, nil, err
return args, nil, nil, err
}
q, err := queryexpr.Parse(expr)
if err != nil {
return nil, nil, err
return args, nil, nil, err
}
if args.NArgs() > 0 {
if args.NArgs() > extraArgs {
var queryArgs ucl.Hashable
if err := args.Bind(&queryArgs); err != nil {
return nil, nil, err
return args, nil, nil, err
}
queryNames := map[string]string{}
@ -97,12 +99,15 @@ func parseQuery(
queryNames[k] = v.String()
switch v.(type) {
switch t := 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
case ucl.BoolObject:
queryValues[k] = &types.AttributeValueMemberBOOL{Value: t.Truthy()}
case attributeValueProxy:
queryValues[k] = t.value
}
return nil
})
@ -114,24 +119,24 @@ func parseQuery(
if args.HasSwitch("table") {
var tblName string
if err := args.BindSwitch("table", &tblName); err != nil {
return nil, nil, err
return args, nil, nil, err
}
tableInfo, err = tablesService.Describe(ctx, tblName)
if err != nil {
return nil, nil, err
return args, nil, nil, err
}
} else if currentRS != nil && currentRS.TableInfo != nil {
tableInfo = currentRS.TableInfo
} else {
return nil, nil, errors.New("no table specified")
return args, nil, nil, errors.New("no table specified")
}
return q, tableInfo, nil
return args, 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)
_, q, tableInfo, err := parseQuery(ctx, args, rs.state.ResultSet(), rs.tableService, 0)
if err != nil {
return nil, err
}

View file

@ -214,6 +214,14 @@ func TestModRS_First(t *testing.T) {
rs = rs:query 'pk="zzz"' -table service-test-data
assert (eq $rs.First ()) "expected First to be nil"
`,
}, {
descr: "returns the first item using placeholders",
cmd: `
rs = rs:query 'pk=$v and sk=$u' [v:"abc" u:"222"] -table service-test-data
assert (eq $rs.First.pk "abc") "expected First.pk == abc"
assert (eq $rs.First.sk "222") "expected First.sk == 222"
assert (eq $rs.First.beta 1231) "expected First.beta == 1231"
`,
},
}
for _, tt := range tests {

View file

@ -2,11 +2,14 @@ package cmdpacks
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/controllers"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"ucl.lmika.dev/ucl"
)
@ -16,6 +19,7 @@ type uiModule struct {
state *controllers.State
ckb *customKeyBinding
readController *controllers.TableReadController
itemRenderer *itemrenderer.Service
}
func (m *uiModule) uiCommand(ctx context.Context, args ucl.CallArgs) (any, error) {
@ -171,7 +175,7 @@ func (m *uiModule) uiBind(ctx context.Context, args ucl.CallArgs) (any, error) {
}
func (m *uiModule) uiQuery(ctx context.Context, args ucl.CallArgs) (any, error) {
q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService)
_, q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService, 0)
if err != nil {
return nil, err
}
@ -191,15 +195,36 @@ func (m *uiModule) uiFilter(ctx context.Context, args ucl.CallArgs) (any, error)
return nil, nil
}
func (m *uiModule) uiSetItemAnnotator(ctx context.Context, args ucl.CallArgs) (any, error) {
var inv ucl.Invokable
if err := args.Bind(&inv); err != nil {
return nil, err
}
m.itemRenderer.SetAnnotation(itemrenderer.AnnotationFunc(func(rs *models.ResultSet, item models.Item, path models.AttrPathNode) string {
v, err := inv.Invoke(ctx, newResultSetProxy(rs), itemProxy{rs, 0, item}, attrPathProxy{attrPath: &path})
if err != nil {
return ""
} else if v == nil {
return ""
}
return fmt.Sprint(v)
}))
commandctrl.QueueRefresh(ctx)
return nil, nil
}
func moduleUI(
tableService *tables.Service,
state *controllers.State,
readController *controllers.TableReadController,
itemRenderer *itemrenderer.Service,
) (ucl.Module, controllers.CustomKeyBindingSource) {
m := &uiModule{
tableService: tableService,
state: state,
readController: readController,
itemRenderer: itemRenderer,
ckb: &customKeyBinding{
bindings: map[string]tea.Cmd{},
keyBindings: map[string]string{},
@ -217,6 +242,7 @@ func moduleUI(
"query": m.uiQuery,
"filter": m.uiFilter,
"bind": m.uiBind,
"set-item-annotator": m.uiSetItemAnnotator,
},
}, m.ckb
}

View file

@ -234,6 +234,49 @@ func (tp itemProxy) Each(fn func(k string, v ucl.Object) error) error {
return nil
}
type attrPathProxy struct {
attrPath *models.AttrPathNode
}
func (ap attrPathProxy) String() string {
return "RSItem()"
}
func (ap attrPathProxy) Truthy() bool {
return true
}
func (ap attrPathProxy) Len() (l int) {
for p := ap.attrPath; p != nil; p = p.Parent {
l++
}
return
}
func (ap attrPathProxy) Index(k int) ucl.Object {
if k == -1 {
return ucl.StringObject(ap.attrPath.Key)
}
if k >= 0 {
k = ap.Len() - k - 1
} else {
k = -k - 1
}
if k < 0 {
return nil
}
for p := ap.attrPath; p != nil; p = p.Parent {
if k <= 0 {
return ucl.StringObject(p.Key)
}
k -= 1
}
return nil
}
type attributeValueProxy struct {
value types.AttributeValue
}

View file

@ -0,0 +1,101 @@
package cmdpacks
import (
"testing"
"github.com/stretchr/testify/assert"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
)
func TestAttrPathProxy_Index(t *testing.T) {
tests := []struct {
descr string
attrPath models.AttrPathNode
index int
want string
wantNil bool
}{
{
descr: "return leaf 1",
attrPath: models.AttrPathNode{Key: "leaf"},
index: -1,
want: "leaf",
},
{
descr: "return leaf 2",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: -1,
want: "leaf",
},
{
descr: "return parent 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: -2,
want: "parent",
},
{
descr: "return parent 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: -2,
want: "parent",
},
{
descr: "return parent 3",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: -3,
want: "grandparent",
},
{
descr: "return root 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: 0,
want: "grandparent",
},
{
descr: "return root 2",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: 0,
want: "parent",
},
{
descr: "return root 3",
attrPath: models.AttrPathNode{Key: "leaf"},
index: 0,
want: "leaf",
},
{
descr: "return first child 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: 1,
want: "parent",
},
{
descr: "return first child 2",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: 1,
want: "leaf",
},
{
descr: "return nil 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: -5,
wantNil: true,
},
{
descr: "return nil 2",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: 56,
wantNil: true,
},
}
for _, tt := range tests {
t.Run(tt.descr, func(t *testing.T) {
proxy := attrPathProxy{&tt.attrPath}
if tt.wantNil {
assert.Nil(t, proxy.Index(tt.index))
} else {
assert.Equal(t, tt.want, proxy.Index(tt.index).String())
}
})
}
}

View file

@ -9,6 +9,7 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/controllers"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"ucl.lmika.dev/repl"
"ucl.lmika.dev/ucl"
@ -23,6 +24,7 @@ type StandardCommands struct {
KeyBindingController *controllers.KeyBindingController
PBProvider services.PasteboardProvider
SettingsController *controllers.SettingsController
ItemRenderer *itemrenderer.Service
modUI ucl.Module
}
@ -36,8 +38,9 @@ func NewStandardCommands(
keyBindingController *controllers.KeyBindingController,
pbProvider services.PasteboardProvider,
settingsController *controllers.SettingsController,
itemRenderer *itemrenderer.Service,
) StandardCommands {
modUI, ckbs := moduleUI(tableService, state, readController)
modUI, ckbs := moduleUI(tableService, state, readController, itemRenderer)
keyBindingController.SetCustomKeyBindingSource(ckbs)
return StandardCommands{
@ -49,6 +52,7 @@ func NewStandardCommands(
KeyBindingController: keyBindingController,
PBProvider: pbProvider,
SettingsController: settingsController,
ItemRenderer: itemRenderer,
modUI: modUI,
}
}
@ -400,6 +404,7 @@ func (sc StandardCommands) InstOptions() []ucl.InstOption {
ucl.WithModule(modulePB(sc.PBProvider)),
ucl.WithModule(moduleOpt(sc.SettingsController)),
ucl.WithModule(moduleAttrValue()),
ucl.WithModule(moduleAsync(sc.TableService, sc.State)),
}
}

View file

@ -2,6 +2,8 @@ package cmdpacks_test
import (
"fmt"
"testing"
tea "github.com/charmbracelet/bubbletea"
bus "github.com/lmika/events"
"github.com/stretchr/testify/assert"
@ -21,7 +23,6 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/keybindings"
"lmika.dev/cmd/dynamo-browse/test/testdynamo"
"lmika.dev/cmd/dynamo-browse/test/testworkspace"
"testing"
)
func TestStdCmds_Mark(t *testing.T) {
@ -162,6 +163,7 @@ func newService(t *testing.T, opts ...serviceOpt) *services {
keyBindingController,
testPB,
settingsController,
itemRendererService,
),
)

View file

@ -4,12 +4,14 @@ import (
"bytes"
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/pkg/errors"
"log"
"os"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/go-co-op/gocron/v2"
"github.com/pkg/errors"
"ucl.lmika.dev/ucl"
"ucl.lmika.dev/ucl/builtins"
@ -17,12 +19,22 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
)
const commandsCategory = "commands"
const (
commandsCategory = "commands"
pendingTaskBuffer = 50
pendingAuxTaskBuffer = 50
auxWorkers = 4
)
type cmdMessage struct {
cmd string
}
type pendingTask struct {
descr string
task func(ctx context.Context) error
}
type CommandController struct {
uclInst *ucl.Inst
historyProvider IterProvider
@ -30,17 +42,27 @@ type CommandController struct {
lookupExtensions []CommandLookupExtension
completionProvider CommandCompletionProvider
uiStateProvider UIStateProvider
cronScheduler gocron.Scheduler
cmdChan chan cmdMessage
pendingTaskChan chan pendingTask
pendingAuxTaskChan chan pendingTask
msgChan chan tea.Msg
interactive bool
}
func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*CommandController, error) {
sched, err := gocron.NewScheduler()
if err != nil {
return nil, err
}
cc := &CommandController{
historyProvider: historyProvider,
commandList: nil,
lookupExtensions: nil,
cronScheduler: sched,
cmdChan: make(chan cmdMessage),
pendingTaskChan: make(chan pendingTask, pendingTaskBuffer),
pendingAuxTaskChan: make(chan pendingTask, pendingAuxTaskBuffer),
msgChan: make(chan tea.Msg),
interactive: true,
}
@ -75,6 +97,8 @@ func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*C
}
go cc.cmdLooper()
go cc.auxCmdLooper()
sched.Start()
return cc, nil
}
@ -172,12 +196,13 @@ func (c *CommandController) Invoke(invokable ucl.Invokable, args []any) (msg tea
}
func (c *CommandController) cmdLooper() {
execCtx := execContext{ctrl: c}
ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx)
ctx := context.Background()
for {
select {
case cmdChan := <-c.cmdChan:
execCtx := execContext{ctrl: c}
ctx := context.WithValue(ctx, commandCtlKey, &execCtx)
res, err := c.ExecuteAndWait(ctx, cmdChan.cmd)
if err != nil {
c.postMessage(events.Error(err))
@ -187,6 +212,16 @@ func (c *CommandController) cmdLooper() {
if execCtx.requestRefresh {
c.postMessage(events.ResultSetUpdated{})
}
case task := <-c.pendingTaskChan:
execCtx := execContext{ctrl: c}
ctx := context.WithValue(ctx, commandCtlKey, &execCtx)
if err := task.task(ctx); err != nil {
c.postMessage(events.Error(err))
}
if execCtx.requestRefresh {
c.postMessage(events.ResultSetUpdated{})
}
}
}
}
@ -305,15 +340,13 @@ func (c *CommandController) cmdInvoker(ctx context.Context, name string, args uc
}
func (c *CommandController) printLine(s string) {
if c.msgChan == nil || !c.interactive {
log.Println(s)
if c.msgChan == nil || !c.interactive {
return
}
select {
case c.msgChan <- events.StatusMsg(s):
default:
log.Println(s)
}
}
@ -325,6 +358,21 @@ func (c *CommandController) postMessage(msg tea.Msg) {
c.msgChan <- msg
}
func (c *CommandController) auxCmdLooper() {
ctx := context.WithValue(context.Background(), commandCtlKey, &execContext{ctrl: c})
for i := 0; i < auxWorkers; i++ {
go func() {
for auxTask := range c.pendingAuxTaskChan {
log.Printf("running aux task: %v", auxTask.descr)
if err := auxTask.task(ctx); err != nil {
log.Printf("aux task error: %v", err)
}
}
}()
}
}
type teaMsgWrapper struct {
msg tea.Msg
}

View file

@ -2,7 +2,10 @@ package commandctrl
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/go-co-op/gocron/v2"
"github.com/pkg/errors"
"ucl.lmika.dev/ucl"
)
@ -57,6 +60,40 @@ func QueueRefresh(ctx context.Context) {
cmdCtl.requestRefresh = true
}
func CronScheduler(ctx context.Context) gocron.Scheduler {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return nil
}
return cmdCtl.ctrl.cronScheduler
}
func ScheduleTask(ctx context.Context, task func(ctx context.Context) error) error {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return errors.New("no command controller")
}
select {
case cmdCtl.ctrl.pendingTaskChan <- pendingTask{task: task}:
return nil
default:
return errors.New("task queue is full")
}
}
func ScheduleAuxTask(ctx context.Context, descr string, task func(ctx context.Context) error) error {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return errors.New("no command controller")
}
select {
case cmdCtl.ctrl.pendingAuxTaskChan <- pendingTask{descr: descr, task: task}:
return nil
default:
return errors.New("aux task queue is full")
}
}
type Invoker interface {
Invoke(invokable ucl.Invokable, args []any) tea.Msg
Inst() *ucl.Inst

View file

@ -0,0 +1,6 @@
package models
type AttrPathNode struct {
Key string
Parent *AttrPathNode
}

View file

@ -2,18 +2,30 @@ package itemrenderer
import (
"fmt"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models/itemrender"
"io"
"text/tabwriter"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models/itemrender"
)
type Service struct {
annotation Annotation
styles styleRenderer
}
func NewService(fileTypeStyle StyleRenderer, metaInfoStyle StyleRenderer) *Service {
func NewService(
fileTypeStyle StyleRenderer,
metaInfoStyle StyleRenderer,
) *Service {
if fileTypeStyle == nil {
fileTypeStyle = plainTextStyleRenderer{}
}
if metaInfoStyle == nil {
metaInfoStyle = plainTextStyleRenderer{}
}
return &Service{
annotation: nil,
styles: styleRenderer{
fileTypeRenderer: fileTypeStyle,
metaInfoRenderer: metaInfoStyle,
@ -21,6 +33,10 @@ func NewService(fileTypeStyle StyleRenderer, metaInfoStyle StyleRenderer) *Servi
}
}
func (s *Service) SetAnnotation(a Annotation) {
s.annotation = a
}
func (s *Service) RenderItem(w io.Writer, item models.Item, resultSet *models.ResultSet, plainText bool) {
styles := s.styles
if plainText {
@ -33,25 +49,47 @@ func (s *Service) RenderItem(w io.Writer, item models.Item, resultSet *models.Re
for _, colName := range resultSet.Columns() {
seenColumns[colName] = struct{}{}
if r := itemrender.ToRenderer(item[colName]); r != nil {
s.renderItem(tabWriter, "", colName, r, styles)
p := models.AttrPathNode{Key: colName}
s.renderItem(tabWriter, resultSet, item, p, "", r, styles)
}
}
for k, _ := range item {
if _, seen := seenColumns[k]; !seen {
if r := itemrender.ToRenderer(item[k]); r != nil {
s.renderItem(tabWriter, "", k, r, styles)
p := models.AttrPathNode{Key: k}
s.renderItem(tabWriter, resultSet, item, p, "", r, styles)
}
}
}
tabWriter.Flush()
}
func (m *Service) renderItem(w io.Writer, prefix string, name string, r itemrender.Renderer, sr styleRenderer) {
fmt.Fprintf(w, "%s%v\t%s\t%s%s\n",
prefix, name, sr.fileTypeRenderer.Render(r.TypeName()), r.StringValue(), sr.metaInfoRenderer.Render(r.MetaInfo()))
func (m *Service) renderItem(
w io.Writer,
resultSet *models.ResultSet,
item models.Item,
path models.AttrPathNode,
prefix string,
r itemrender.Renderer,
sr styleRenderer,
) {
fmt.Fprint(w, prefix)
fmt.Fprint(w, path.Key)
fmt.Fprint(w, "\t")
fmt.Fprint(w, sr.fileTypeRenderer.Render(r.TypeName()))
fmt.Fprint(w, "\t")
fmt.Fprint(w, r.StringValue())
fmt.Fprint(w, sr.metaInfoRenderer.Render(r.MetaInfo()))
if m.annotation != nil {
fmt.Fprint(w, " ")
fmt.Fprint(w, sr.metaInfoRenderer.Render(m.annotation.AnnotateAttribute(resultSet, item, path)))
}
fmt.Fprint(w, "\n")
if subitems := r.SubItems(); len(subitems) > 0 {
for _, si := range subitems {
m.renderItem(w, prefix+" ", si.Key, si.Value, sr)
p := models.AttrPathNode{Key: si.Key, Parent: &path}
m.renderItem(w, resultSet, item, p, prefix+" ", si.Value, sr)
}
}
}
@ -60,3 +98,13 @@ type styleRenderer struct {
fileTypeRenderer StyleRenderer
metaInfoRenderer StyleRenderer
}
type Annotation interface {
AnnotateAttribute(rs *models.ResultSet, item models.Item, path models.AttrPathNode) string
}
type AnnotationFunc func(rs *models.ResultSet, item models.Item, path models.AttrPathNode) string
func (af AnnotationFunc) AnnotateAttribute(rs *models.ResultSet, item models.Item, path models.AttrPathNode) string {
return af(rs, item, path)
}

View file

@ -1,6 +1,8 @@
package dynamoitemview
import (
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@ -9,7 +11,6 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/frame"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles"
"strings"
)
type Model struct {

View file

@ -20,7 +20,7 @@ type ItemViewStyle struct {
var DefaultStyles = Styles{
ItemView: ItemViewStyle{
FieldType: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#2B800C", Dark: "#73C653"}),
MetaInfo: lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")),
MetaInfo: lipgloss.NewStyle().Foreground(lipgloss.Color("#707070")),
},
Frames: frame.Style{
ActiveTitle: lipgloss.NewStyle().