From c8b65f6b0a54f371b56154212fe8b2982d4e39ea Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 29 Oct 2025 22:15:26 +1100 Subject: [PATCH] Added some quality of life changes - 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 --- .../ui/commandctrl/cmdpacks/modrs_test.go | 34 ++++ .../common/ui/commandctrl/cmdpacks/proxy.go | 25 +++ .../common/ui/commandctrl/cmdpacks/pvars.go | 55 +++++- .../ui/commandctrl/cmdpacks/pvars_test.go | 32 +++ .../common/ui/commandctrl/cmdpacks/stdcmds.go | 13 +- .../dynamo-browse/controllers/tableread.go | 4 +- internal/dynamo-browse/models/models.go | 35 +++- internal/dynamo-browse/models/models_test.go | 183 ++++++++++++++++++ .../models/queryexpr/expr_test.go | 5 +- .../dynamo-browse/ui/keybindings/defaults.go | 1 - .../ui/keybindings/keybindings.go | 1 - internal/dynamo-browse/ui/model.go | 7 +- 12 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 internal/common/ui/commandctrl/cmdpacks/pvars_test.go create mode 100644 internal/dynamo-browse/models/models_test.go diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go index d4ce9a2..b3f6f82 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go +++ b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go @@ -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 diff --git a/internal/common/ui/commandctrl/cmdpacks/proxy.go b/internal/common/ui/commandctrl/cmdpacks/proxy.go index 8461517..9aa9fb3 100644 --- a/internal/common/ui/commandctrl/cmdpacks/proxy.go +++ b/internal/common/ui/commandctrl/cmdpacks/proxy.go @@ -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 diff --git a/internal/common/ui/commandctrl/cmdpacks/pvars.go b/internal/common/ui/commandctrl/cmdpacks/pvars.go index 1aa0d85..754bbcb 100644 --- a/internal/common/ui/commandctrl/cmdpacks/pvars.go +++ b/internal/common/ui/commandctrl/cmdpacks/pvars.go @@ -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 { diff --git a/internal/common/ui/commandctrl/cmdpacks/pvars_test.go b/internal/common/ui/commandctrl/cmdpacks/pvars_test.go new file mode 100644 index 0000000..bae0f30 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/pvars_test.go @@ -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) + }) + } +} diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go index fce0b96..326543b 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -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 + } +} ` diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 797c626..6204f1a 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -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 diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index ff4e398..ce159d1 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -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 } diff --git a/internal/dynamo-browse/models/models_test.go b/internal/dynamo-browse/models/models_test.go new file mode 100644 index 0000000..b25be6e --- /dev/null +++ b/internal/dynamo-browse/models/models_test.go @@ -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) + }) +} diff --git a/internal/dynamo-browse/models/queryexpr/expr_test.go b/internal/dynamo-browse/models/queryexpr/expr_test.go index 96108db..ed004c5 100644 --- a/internal/dynamo-browse/models/queryexpr/expr_test.go +++ b/internal/dynamo-browse/models/queryexpr/expr_test.go @@ -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}}, diff --git a/internal/dynamo-browse/ui/keybindings/defaults.go b/internal/dynamo-browse/ui/keybindings/defaults.go index 818919a..6f71e3f 100644 --- a/internal/dynamo-browse/ui/keybindings/defaults.go +++ b/internal/dynamo-browse/ui/keybindings/defaults.go @@ -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")), diff --git a/internal/dynamo-browse/ui/keybindings/keybindings.go b/internal/dynamo-browse/ui/keybindings/keybindings.go index 96c24b3..5014fa6 100644 --- a/internal/dynamo-browse/ui/keybindings/keybindings.go +++ b/internal/dynamo-browse/ui/keybindings/keybindings.go @@ -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"` diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index ecbe9e2..d0e6608 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -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))