Added some quality of life changes
All checks were successful
ci / Build (push) Successful in 3m40s

- Fixed the 'M' bind to mark all/none items, rather than toggle
- Fixed panic which was raised when reqesting @item with an index beyond the length of resultset
- Added changing the table when calling @table with a table or string name
This commit is contained in:
Leon Mika 2025-10-29 22:15:26 +11:00
parent 85a4f0b5e9
commit c8b65f6b0a
12 changed files with 380 additions and 15 deletions

View file

@ -154,6 +154,40 @@ func TestModRS_Query(t *testing.T) {
}
}
func TestModRS_Filter(t *testing.T) {
tests := []struct {
descr string
cmd string
}{
{
descr: "returns filtered items 1",
cmd: `
rs = rs:scan -table service-test-data
rs = rs:filter $rs 'pk="abc"'
assert (len $rs) "expected len == 2"
assert (eq $rs.First.pk "abc") "expected First.pk == abc"
`,
},
//{
// descr: "returns filtered items 2",
// cmd: `
// rs = rs:scan -table service-test-data
// rs = rs:filter $rs 'pk="bbb"'
// assert (len $rs) "expected len == 1"
// assert (eq $rs.First.pk "bbb") "expected First.pk == bbb"
// `,
//},
}
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)
})
}
}
func TestModRS_First(t *testing.T) {
tests := []struct {
descr string

View file

@ -174,6 +174,31 @@ func (tp resultSetItemsProxy) Index(k int) ucl.Object {
return itemProxy{resultSet: tp.resultSet, idx: k, item: tp.resultSet.Items()[k]}
}
type resultSetMarkedItemsProxy struct {
resultSet *models.ResultSet
}
func (ip resultSetMarkedItemsProxy) String() string {
return fmt.Sprintf("MarkedItems(%v)", len(ip.resultSet.MarkedItems()))
}
func (ip resultSetMarkedItemsProxy) Truthy() bool {
return len(ip.resultSet.MarkedItems()) > 0
}
func (tp resultSetMarkedItemsProxy) Len() int {
return len(tp.resultSet.MarkedItems())
}
func (tp resultSetMarkedItemsProxy) Index(k int) ucl.Object {
markedItems := tp.resultSet.MarkedItems()
if k >= len(markedItems) {
return nil
}
actualItem := tp.resultSet.Items()[markedItems[k].Index]
return itemProxy{resultSet: tp.resultSet, idx: markedItems[k].Index, item: actualItem}
}
type itemProxy struct {
resultSet *models.ResultSet
idx int

View file

@ -2,20 +2,53 @@ package cmdpacks
import (
"context"
"github.com/pkg/errors"
"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/models"
"github.com/pkg/errors"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
)
type tablePVar struct {
state *controllers.State
state *controllers.State
tableService *tables.Service
readController *controllers.TableReadController
}
func (rs tablePVar) Get(ctx context.Context) (any, error) {
return newTableProxy(rs.state.ResultSet().TableInfo), nil
}
func (rs tablePVar) Set(ctx context.Context, value any) error {
scanNewTable := func(name string) error {
tableInfo, err := rs.tableService.Describe(ctx, name)
if err != nil {
return errors.Wrapf(err, "cannot describe %v", name)
}
resultSet, err := rs.tableService.Scan(ctx, tableInfo)
if resultSet != nil {
resultSet = rs.tableService.Filter(resultSet, rs.state.Filter())
}
msg := rs.readController.SetResultSet(resultSet)
commandctrl.PostMsg(ctx, msg)
return nil
}
tblVal, ok := value.(SimpleProxy[*models.TableInfo])
if ok {
return scanNewTable(tblVal.value.Name)
}
strVal, ok := value.(string)
if ok {
return scanNewTable(strVal)
}
return errors.New("new value to @table is not a table name")
}
type resultSetPVar struct {
state *controllers.State
readController *controllers.TableReadController
@ -36,6 +69,15 @@ func (rs resultSetPVar) Set(ctx context.Context, value any) error {
return nil
}
type markedSetPVar struct {
state *controllers.State
readController *controllers.TableReadController
}
func (rs markedSetPVar) Get(ctx context.Context) (any, error) {
return resultSetMarkedItemsProxy{rs.state.ResultSet()}, nil
}
type itemPVar struct {
state *controllers.State
}
@ -43,9 +85,14 @@ type itemPVar struct {
func (rs itemPVar) Get(ctx context.Context) (any, error) {
selItem, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
return nil, nil
}
return itemProxy{rs.state.ResultSet(), selItem, rs.state.ResultSet().Items()[selItem]}, nil
rset := rs.state.ResultSet()
if selItem < 0 || selItem >= len(rs.state.ResultSet().Items()) {
return nil, nil
}
return itemProxy{rset, selItem, rset.Items()[selItem]}, nil
}
func (rs itemPVar) Set(ctx context.Context, value any) error {

View file

@ -0,0 +1,32 @@
package cmdpacks_test
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPVars(t *testing.T) {
tests := []struct {
descr string
cmd string
}{
{
descr: "returns item on empty result set",
cmd: `
ui:query '"a"="1"' -table service-test-data
@item
`,
},
}
for _, tt := range tests {
t.Run(tt.descr, func(t *testing.T) {
svc := newService(t)
ctx := t.Context()
_, err := svc.CommandController.ExecuteAndWait(ctx, tt.cmd)
assert.NoError(t, err)
})
}
}

View file

@ -2,6 +2,7 @@ package cmdpacks
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/pkg/errors"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
@ -426,8 +427,9 @@ func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) {
ucl.SetBuiltin("q", sc.cmdQuit)
ucl.SetPseudoVar("resultset", resultSetPVar{sc.State, sc.ReadController})
ucl.SetPseudoVar("table", tablePVar{sc.State})
ucl.SetPseudoVar("table", tablePVar{sc.State, sc.TableService, sc.ReadController})
ucl.SetPseudoVar("item", itemPVar{sc.State})
ucl.SetPseudoVar("marked", markedSetPVar{sc.State, sc.ReadController})
}
func (sc StandardCommands) RunPrelude(ctx context.Context, ucl *ucl.Inst) error {
@ -438,4 +440,13 @@ func (sc StandardCommands) RunPrelude(ctx context.Context, ucl *ucl.Inst) error
const uclPrelude = `
ui:command unmark { mark none }
ui:command set-opt { |n k| opt:set $n $k }
ui:bind "view.toggle-marked-items" "M" {
markedCount = len @marked
if (eq $markedCount (len @resultset)) {
mark none
} else {
mark all
}
}
`

View file

@ -10,6 +10,8 @@ import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
tea "github.com/charmbracelet/bubbletea"
bus "github.com/lmika/events"
"github.com/pkg/errors"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models/attrcodec"
@ -20,8 +22,6 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/inputhistory"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/viewsnapshot"
bus "github.com/lmika/events"
"github.com/pkg/errors"
)
type resultSetUpdateOp int

View file

@ -1,9 +1,11 @@
package models
import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"sort"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
type ResultSet struct {
@ -20,6 +22,10 @@ type ResultSet struct {
columns []string
sortCriteria SortCriteria
mutex sync.Mutex
cachedMarkedItems []ItemIndex
hasCachedMarkedItems bool
}
type Queryable interface {
@ -47,6 +53,11 @@ func (rs *ResultSet) Items() []Item {
func (rs *ResultSet) SetItems(items []Item) {
rs.items = items
rs.attributes = make([]ItemAttribute, len(items))
rs.mutex.Lock()
defer rs.mutex.Unlock()
rs.hasCachedMarkedItems = false
rs.cachedMarkedItems = nil
}
func (rs *ResultSet) SortCriteria() SortCriteria {
@ -56,10 +67,24 @@ func (rs *ResultSet) SortCriteria() SortCriteria {
func (rs *ResultSet) AddNewItem(item Item, attrs ItemAttribute) {
rs.items = append(rs.items, item)
rs.attributes = append(rs.attributes, attrs)
rs.mutex.Lock()
defer rs.mutex.Unlock()
rs.hasCachedMarkedItems = false
rs.cachedMarkedItems = nil
}
func (rs *ResultSet) SetMark(idx int, marked bool) {
rs.attributes[idx].Marked = marked
if !rs.hasCachedMarkedItems {
return
}
rs.mutex.Lock()
defer rs.mutex.Unlock()
rs.hasCachedMarkedItems = false
rs.cachedMarkedItems = nil
}
func (rs *ResultSet) SetHidden(idx int, hidden bool) {
@ -91,12 +116,20 @@ func (rs *ResultSet) IsNew(idx int) bool {
}
func (rs *ResultSet) MarkedItems() []ItemIndex {
rs.mutex.Lock()
defer rs.mutex.Unlock()
if rs.hasCachedMarkedItems {
return rs.cachedMarkedItems
}
items := make([]ItemIndex, 0)
for i, itemAttr := range rs.attributes {
if itemAttr.Marked && !itemAttr.Hidden {
items = append(items, ItemIndex{Index: i, Item: rs.items[i]})
}
}
rs.cachedMarkedItems = items
rs.hasCachedMarkedItems = true
return items
}

View file

@ -0,0 +1,183 @@
package models
import (
"testing"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/stretchr/testify/assert"
)
func TestMarkedItems(t *testing.T) {
t.Run("SetMark properly reflected in MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Initially, no items should be marked
assert.Len(t, rs.MarkedItems(), 0)
// Mark the first item
rs.SetMark(0, true)
markedItems := rs.MarkedItems()
assert.Len(t, markedItems, 1)
assert.Equal(t, 0, markedItems[0].Index)
// Mark the third item
rs.SetMark(2, true)
markedItems = rs.MarkedItems()
assert.Len(t, markedItems, 2)
assert.Equal(t, 0, markedItems[0].Index)
assert.Equal(t, 2, markedItems[1].Index)
// Verify the items themselves are correct
item1, ok1 := markedItems[0].Item.AttributeValueAsString("id")
item2, ok2 := markedItems[1].Item.AttributeValueAsString("id")
assert.True(t, ok1)
assert.True(t, ok2)
assert.Equal(t, "item1", item1)
assert.Equal(t, "item3", item2)
})
t.Run("item with Marked=true is in MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Directly set the Marked attribute to true for item at index 1
rs.SetMark(1, true)
markedItems := rs.MarkedItems()
assert.Len(t, markedItems, 1)
assert.Equal(t, 1, markedItems[0].Index)
item, ok := markedItems[0].Item.AttributeValueAsString("id")
assert.True(t, ok)
assert.Equal(t, "item2", item)
})
t.Run("adding marked items affects result of MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Mark all items
rs.SetMark(0, true)
rs.SetMark(1, true)
assert.Len(t, rs.MarkedItems(), 2)
markedItems := rs.MarkedItems()
expectedIndices := []int{0, 1}
for i, expected := range expectedIndices {
assert.Equal(t, expected, markedItems[i].Index)
}
// Add a new unmarked item
rs.AddNewItem(Item{"id": &types.AttributeValueMemberS{Value: "item4"}}, ItemAttribute{})
assert.Len(t, rs.MarkedItems(), 2)
// Add a new marked item
rs.AddNewItem(Item{"id": &types.AttributeValueMemberS{Value: "item5"}}, ItemAttribute{Marked: true})
assert.Len(t, rs.MarkedItems(), 3)
markedItems = rs.MarkedItems()
expectedIndices = []int{0, 1, 4}
for i, expected := range expectedIndices {
assert.Equal(t, expected, markedItems[i].Index)
}
})
t.Run("changing SetMark updates length of MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
{"id": &types.AttributeValueMemberS{Value: "item4"}},
})
// Mark all items
rs.SetMark(0, true)
rs.SetMark(1, true)
rs.SetMark(2, true)
rs.SetMark(3, true)
assert.Len(t, rs.MarkedItems(), 4)
// Unmark one item
rs.SetMark(1, false)
assert.Len(t, rs.MarkedItems(), 3)
// Verify the correct items are marked
markedItems := rs.MarkedItems()
expectedIndices := []int{0, 2, 3}
for i, expected := range expectedIndices {
assert.Equal(t, expected, markedItems[i].Index)
}
// Unmark all remaining items
rs.SetMark(0, false)
rs.SetMark(2, false)
rs.SetMark(3, false)
assert.Len(t, rs.MarkedItems(), 0)
})
t.Run("changing items clears all marked items", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Mark all items
rs.SetMark(0, true)
rs.SetMark(1, true)
rs.SetMark(2, true)
assert.Len(t, rs.MarkedItems(), 3)
// Call SetItems with new items
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "newitem1"}},
{"id": &types.AttributeValueMemberS{Value: "newitem2"}},
})
// All marks should be cleared
assert.Len(t, rs.MarkedItems(), 0)
// Verify none of the new items are marked
assert.False(t, rs.Marked(0))
assert.False(t, rs.Marked(1))
})
t.Run("hidden items are excluded from MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Mark all items
rs.SetMark(0, true)
rs.SetMark(1, true)
rs.SetMark(2, true)
// Hide the second item
rs.SetHidden(1, true)
markedItems := rs.MarkedItems()
assert.Len(t, markedItems, 2)
// Verify only items 0 and 2 are in the marked items
assert.Equal(t, 0, markedItems[0].Index)
assert.Equal(t, 2, markedItems[1].Index)
})
}

View file

@ -10,8 +10,8 @@ import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"github.com/stretchr/testify/assert"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
)
func TestModExpr_Query(t *testing.T) {
@ -502,6 +502,9 @@ func TestQueryExpr_EvalItem(t *testing.T) {
{expr: `alpha^="al"`, expected: &types.AttributeValueMemberBOOL{Value: true}},
{expr: `alpha="foobar"`, expected: &types.AttributeValueMemberBOOL{Value: false}},
{expr: `alpha^="need-something"`, expected: &types.AttributeValueMemberBOOL{Value: false}},
{expr: `""=""`, expected: &types.AttributeValueMemberBOOL{Value: true}},
{expr: `"abc"="abc"`, expected: &types.AttributeValueMemberBOOL{Value: true}},
{expr: `""="abc"`, expected: &types.AttributeValueMemberBOOL{Value: false}},
// Comparison
{expr: "three > 4", expected: &types.AttributeValueMemberBOOL{Value: false}},

View file

@ -26,7 +26,6 @@ func Default() *KeyBindings {
},
View: &ViewKeyBindings{
Mark: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "mark")),
ToggleMarkedItems: key.NewBinding(key.WithKeys("M"), key.WithHelp("M", "toggle marged items")),
CopyItemToClipboard: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy item to clipboard")),
CopyTableToClipboard: key.NewBinding(key.WithKeys("C"), key.WithHelp("C", "copy table to clipboard")),
Rescan: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "rescan")),

View file

@ -32,7 +32,6 @@ type TableKeyBinding struct {
type ViewKeyBindings struct {
Mark key.Binding `keymap:"mark"`
ToggleMarkedItems key.Binding `keymap:"toggle-marked-items"`
CopyItemToClipboard key.Binding `keymap:"copy-item-to-clipboard"`
CopyTableToClipboard key.Binding `keymap:"copy-table-to-clipboard"`
Rescan key.Binding `keymap:"rescan"`

View file

@ -1,8 +1,11 @@
package ui
import (
"log"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
bus "github.com/lmika/events"
"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"
@ -20,8 +23,6 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/tableselect"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils"
bus "github.com/lmika/events"
"log"
)
const (
@ -125,8 +126,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, events.SetTeaMessage(m.tableWriteController.ToggleMark(idx))
}
case key.Matches(msg, m.keyMap.ToggleMarkedItems):
return m, events.SetTeaMessage(m.tableReadController.Mark(controllers.MarkOpToggle, ""))
case key.Matches(msg, m.keyMap.CopyItemToClipboard):
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, events.SetTeaMessage(m.tableReadController.CopyItemToClipboard(idx))