chore: safe slice

This commit is contained in:
Kujtim Hoxha
2025-07-24 22:19:01 +02:00
parent bb668b1b0f
commit 4614a6cdcf
8 changed files with 438 additions and 61 deletions

View File

@@ -1 +1 @@
{"flagWords":[],"version":"0.2","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm"],"language":"en"}
{"flagWords":[],"version":"0.2","language":"en","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync"]}

View File

@@ -2,6 +2,7 @@ package csync
import (
"iter"
"slices"
"sync"
)
@@ -34,3 +35,129 @@ func (s *LazySlice[K]) Seq() iter.Seq[K] {
}
}
}
// Slice is a thread-safe slice implementation that provides concurrent access.
type Slice[T any] struct {
inner []T
mu sync.RWMutex
}
// NewSlice creates a new thread-safe slice.
func NewSlice[T any]() *Slice[T] {
return &Slice[T]{
inner: make([]T, 0),
}
}
// NewSliceFrom creates a new thread-safe slice from an existing slice.
func NewSliceFrom[T any](s []T) *Slice[T] {
inner := make([]T, len(s))
copy(inner, s)
return &Slice[T]{
inner: inner,
}
}
// Append adds an element to the end of the slice.
func (s *Slice[T]) Append(item T) {
s.mu.Lock()
defer s.mu.Unlock()
s.inner = append(s.inner, item)
}
// Prepend adds an element to the beginning of the slice.
func (s *Slice[T]) Prepend(item T) {
s.mu.Lock()
defer s.mu.Unlock()
s.inner = append([]T{item}, s.inner...)
}
// Delete removes the element at the specified index.
func (s *Slice[T]) Delete(index int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(s.inner) {
return false
}
s.inner = slices.Delete(s.inner, index, index+1)
return true
}
// Get returns the element at the specified index.
func (s *Slice[T]) Get(index int) (T, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
var zero T
if index < 0 || index >= len(s.inner) {
return zero, false
}
return s.inner[index], true
}
// Set updates the element at the specified index.
func (s *Slice[T]) Set(index int, item T) bool {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(s.inner) {
return false
}
s.inner[index] = item
return true
}
// Len returns the number of elements in the slice.
func (s *Slice[T]) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.inner)
}
// Slice returns a copy of the underlying slice.
func (s *Slice[T]) Slice() []T {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]T, len(s.inner))
copy(result, s.inner)
return result
}
// SetSlice replaces the entire slice with a new one.
func (s *Slice[T]) SetSlice(items []T) {
s.mu.Lock()
defer s.mu.Unlock()
s.inner = make([]T, len(items))
copy(s.inner, items)
}
// Clear removes all elements from the slice.
func (s *Slice[T]) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.inner = s.inner[:0]
}
// Seq returns an iterator that yields elements from the slice.
func (s *Slice[T]) Seq() iter.Seq[T] {
// Take a snapshot to avoid holding the lock during iteration
items := s.Slice()
return func(yield func(T) bool) {
for _, v := range items {
if !yield(v) {
return
}
}
}
}
// SeqWithIndex returns an iterator that yields index-value pairs from the slice.
func (s *Slice[T]) SeqWithIndex() iter.Seq2[int, T] {
// Take a snapshot to avoid holding the lock during iteration
items := s.Slice()
return func(yield func(int, T) bool) {
for i, v := range items {
if !yield(i, v) {
return
}
}
}
}

View File

@@ -1,11 +1,13 @@
package csync
import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLazySlice_Seq(t *testing.T) {
@@ -85,3 +87,210 @@ func TestLazySlice_EarlyBreak(t *testing.T) {
assert.Equal(t, []string{"a", "b"}, result)
}
func TestSlice(t *testing.T) {
t.Run("NewSlice", func(t *testing.T) {
s := NewSlice[int]()
assert.Equal(t, 0, s.Len())
})
t.Run("NewSliceFrom", func(t *testing.T) {
original := []int{1, 2, 3}
s := NewSliceFrom(original)
assert.Equal(t, 3, s.Len())
// Verify it's a copy, not a reference
original[0] = 999
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, 1, val)
})
t.Run("Append", func(t *testing.T) {
s := NewSlice[string]()
s.Append("hello")
s.Append("world")
assert.Equal(t, 2, s.Len())
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, "hello", val)
val, ok = s.Get(1)
require.True(t, ok)
assert.Equal(t, "world", val)
})
t.Run("Prepend", func(t *testing.T) {
s := NewSlice[string]()
s.Append("world")
s.Prepend("hello")
assert.Equal(t, 2, s.Len())
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, "hello", val)
val, ok = s.Get(1)
require.True(t, ok)
assert.Equal(t, "world", val)
})
t.Run("Delete", func(t *testing.T) {
s := NewSliceFrom([]int{1, 2, 3, 4, 5})
// Delete middle element
ok := s.Delete(2)
assert.True(t, ok)
assert.Equal(t, 4, s.Len())
expected := []int{1, 2, 4, 5}
actual := s.Slice()
assert.Equal(t, expected, actual)
// Delete out of bounds
ok = s.Delete(10)
assert.False(t, ok)
assert.Equal(t, 4, s.Len())
// Delete negative index
ok = s.Delete(-1)
assert.False(t, ok)
assert.Equal(t, 4, s.Len())
})
t.Run("Get", func(t *testing.T) {
s := NewSliceFrom([]string{"a", "b", "c"})
val, ok := s.Get(1)
require.True(t, ok)
assert.Equal(t, "b", val)
// Out of bounds
_, ok = s.Get(10)
assert.False(t, ok)
// Negative index
_, ok = s.Get(-1)
assert.False(t, ok)
})
t.Run("Set", func(t *testing.T) {
s := NewSliceFrom([]string{"a", "b", "c"})
ok := s.Set(1, "modified")
assert.True(t, ok)
val, ok := s.Get(1)
require.True(t, ok)
assert.Equal(t, "modified", val)
// Out of bounds
ok = s.Set(10, "invalid")
assert.False(t, ok)
// Negative index
ok = s.Set(-1, "invalid")
assert.False(t, ok)
})
t.Run("SetSlice", func(t *testing.T) {
s := NewSlice[int]()
s.Append(1)
s.Append(2)
newItems := []int{10, 20, 30}
s.SetSlice(newItems)
assert.Equal(t, 3, s.Len())
assert.Equal(t, newItems, s.Slice())
// Verify it's a copy
newItems[0] = 999
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, 10, val)
})
t.Run("Clear", func(t *testing.T) {
s := NewSliceFrom([]int{1, 2, 3})
assert.Equal(t, 3, s.Len())
s.Clear()
assert.Equal(t, 0, s.Len())
})
t.Run("Slice", func(t *testing.T) {
original := []int{1, 2, 3}
s := NewSliceFrom(original)
copy := s.Slice()
assert.Equal(t, original, copy)
// Verify it's a copy
copy[0] = 999
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, 1, val)
})
t.Run("Seq", func(t *testing.T) {
s := NewSliceFrom([]int{1, 2, 3})
var result []int
for v := range s.Seq() {
result = append(result, v)
}
assert.Equal(t, []int{1, 2, 3}, result)
})
t.Run("SeqWithIndex", func(t *testing.T) {
s := NewSliceFrom([]string{"a", "b", "c"})
var indices []int
var values []string
for i, v := range s.SeqWithIndex() {
indices = append(indices, i)
values = append(values, v)
}
assert.Equal(t, []int{0, 1, 2}, indices)
assert.Equal(t, []string{"a", "b", "c"}, values)
})
t.Run("ConcurrentAccess", func(t *testing.T) {
s := NewSlice[int]()
const numGoroutines = 100
const itemsPerGoroutine = 10
var wg sync.WaitGroup
// Concurrent appends
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(start int) {
defer wg.Done()
for j := 0; j < itemsPerGoroutine; j++ {
s.Append(start*itemsPerGoroutine + j)
}
}(i)
}
// Concurrent reads
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < itemsPerGoroutine; j++ {
s.Len() // Just read the length
}
}()
}
wg.Wait()
// Should have all items
assert.Equal(t, numGoroutines*itemsPerGoroutine, s.Len())
})
}

View File

@@ -97,7 +97,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
f.list = New[T](items, f.listOptions...).(*list[T])
f.updateKeyMaps()
f.items = f.list.items
f.items = f.list.items.Slice()
if f.inputHidden {
return f

View File

@@ -179,7 +179,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
var cmds []tea.Cmd
for _, item := range f.items {
for _, item := range f.items.Slice() {
if i, ok := any(item).(layout.Focusable); ok {
cmds = append(cmds, i.Blur())
}

View File

@@ -38,6 +38,7 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T
keyMap: DefaultKeyMap(),
focused: true,
},
items: csync.NewSlice[Item](),
indexMap: csync.NewMap[string, int](),
renderedItems: csync.NewMap[string, renderedItem](),
}
@@ -82,13 +83,13 @@ func (g *groupedList[T]) convertItems() {
items = append(items, g)
}
}
g.items = items
g.items.SetSlice(items)
}
func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
g.groups = groups
g.convertItems()
return g.SetItems(g.items)
return g.SetItems(g.items.Slice())
}
func (g *groupedList[T]) Groups() []Group[T] {

View File

@@ -1,7 +1,6 @@
package list
import (
"slices"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
@@ -86,7 +85,7 @@ type list[T Item] struct {
offset int
indexMap *csync.Map[string, int]
items []T
items *csync.Slice[T]
renderedItems *csync.Map[string, renderedItem]
@@ -164,7 +163,7 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
keyMap: DefaultKeyMap(),
focused: true,
},
items: items,
items: csync.NewSliceFrom(items),
indexMap: csync.NewMap[string, int](),
renderedItems: csync.NewMap[string, renderedItem](),
}
@@ -191,7 +190,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case anim.StepMsg:
var cmds []tea.Cmd
for _, item := range l.items {
for _, item := range l.items.Slice() {
if i, ok := any(item).(HasAnim); ok && i.Spinning() {
updated, cmd := i.Update(msg)
cmds = append(cmds, cmd)
@@ -267,7 +266,7 @@ func (l *list[T]) viewPosition() (int, int) {
func (l *list[T]) recalculateItemPositions() {
currentContentHeight := 0
for _, item := range l.items {
for _, item := range l.items.Slice() {
rItem, ok := l.renderedItems.Get(item.ID())
if !ok {
continue
@@ -280,7 +279,7 @@ func (l *list[T]) recalculateItemPositions() {
}
func (l *list[T]) render() tea.Cmd {
if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
return nil
}
l.setDefaultSelected()
@@ -424,19 +423,23 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
if inx == ItemNotFound {
return nil
}
item, ok := l.renderedItems.Get(l.items[inx].ID())
item, ok := l.items.Get(inx)
if !ok {
continue
}
renderedItem, ok := l.renderedItems.Get(item.ID())
if !ok {
continue
}
// If the item is bigger than the viewport, select it
if item.start <= start && item.end >= end {
l.selectedItem = item.id
if renderedItem.start <= start && renderedItem.end >= end {
l.selectedItem = renderedItem.id
return l.render()
}
// item is in the view
if item.start >= start && item.start <= end {
l.selectedItem = item.id
if renderedItem.start >= start && renderedItem.start <= end {
l.selectedItem = renderedItem.id
return l.render()
}
}
@@ -452,19 +455,23 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
if inx == ItemNotFound {
return nil
}
item, ok := l.renderedItems.Get(l.items[inx].ID())
item, ok := l.items.Get(inx)
if !ok {
continue
}
renderedItem, ok := l.renderedItems.Get(item.ID())
if !ok {
continue
}
// If the item is bigger than the viewport, select it
if item.start <= start && item.end >= end {
l.selectedItem = item.id
if renderedItem.start <= start && renderedItem.end >= end {
l.selectedItem = renderedItem.id
return l.render()
}
// item is in the view
if item.end >= start && item.end <= end {
l.selectedItem = item.id
if renderedItem.end >= start && renderedItem.end <= end {
l.selectedItem = renderedItem.id
return l.render()
}
}
@@ -475,36 +482,51 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
func (l *list[T]) selectFirstItem() {
inx := l.firstSelectableItemBelow(-1)
if inx != ItemNotFound {
l.selectedItem = l.items[inx].ID()
item, ok := l.items.Get(inx)
if ok {
l.selectedItem = item.ID()
}
}
}
func (l *list[T]) selectLastItem() {
inx := l.firstSelectableItemAbove(len(l.items))
inx := l.firstSelectableItemAbove(l.items.Len())
if inx != ItemNotFound {
l.selectedItem = l.items[inx].ID()
item, ok := l.items.Get(inx)
if ok {
l.selectedItem = item.ID()
}
}
}
func (l *list[T]) firstSelectableItemAbove(inx int) int {
for i := inx - 1; i >= 0; i-- {
if _, ok := any(l.items[i]).(layout.Focusable); ok {
item, ok := l.items.Get(i)
if !ok {
continue
}
if _, ok := any(item).(layout.Focusable); ok {
return i
}
}
if inx == 0 && l.wrap {
return l.firstSelectableItemAbove(len(l.items))
return l.firstSelectableItemAbove(l.items.Len())
}
return ItemNotFound
}
func (l *list[T]) firstSelectableItemBelow(inx int) int {
for i := inx + 1; i < len(l.items); i++ {
if _, ok := any(l.items[i]).(layout.Focusable); ok {
itemsLen := l.items.Len()
for i := inx + 1; i < itemsLen; i++ {
item, ok := l.items.Get(i)
if !ok {
continue
}
if _, ok := any(item).(layout.Focusable); ok {
return i
}
}
if inx == len(l.items)-1 && l.wrap {
if inx == itemsLen-1 && l.wrap {
return l.firstSelectableItemBelow(-1)
}
return ItemNotFound
@@ -515,7 +537,7 @@ func (l *list[T]) focusSelectedItem() tea.Cmd {
return nil
}
var cmds []tea.Cmd
for _, item := range l.items {
for _, item := range l.items.Slice() {
if f, ok := any(item).(layout.Focusable); ok {
if item.ID() == l.selectedItem && !f.IsFocused() {
cmds = append(cmds, f.Focus())
@@ -534,7 +556,7 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
return nil
}
var cmds []tea.Cmd
for _, item := range l.items {
for _, item := range l.items.Slice() {
if f, ok := any(item).(layout.Focusable); ok {
if item.ID() == l.selectedItem && f.IsFocused() {
cmds = append(cmds, f.Blur())
@@ -549,7 +571,8 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
// returns the last index
func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
currentContentHeight := lipgloss.Height(l.rendered) - 1
for i := startInx; i < len(l.items); i++ {
itemsLen := l.items.Len()
for i := startInx; i < itemsLen; i++ {
if currentContentHeight >= l.height && limitHeight {
return i
}
@@ -557,10 +580,13 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
inx := i
if l.direction != DirectionForward {
inx = (len(l.items) - 1) - i
inx = (itemsLen - 1) - i
}
item := l.items[inx]
item, ok := l.items.Get(inx)
if !ok {
continue
}
var rItem renderedItem
if cache, ok := l.renderedItems.Get(item.ID()); ok {
rItem = cache
@@ -571,7 +597,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
l.renderedItems.Set(item.ID(), rItem)
}
gap := l.gap + 1
if inx == len(l.items)-1 {
if inx == itemsLen-1 {
gap = 0
}
@@ -582,7 +608,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
}
currentContentHeight = rItem.end + 1 + l.gap
}
return len(l.items)
return itemsLen
}
func (l *list[T]) renderItem(item Item) renderedItem {
@@ -602,9 +628,9 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
cmds = append(cmds, cmd)
}
l.items = append(l.items, item)
l.items.Append(item)
l.indexMap = csync.NewMap[string, int]()
for inx, item := range l.items {
for inx, item := range l.items.Slice() {
l.indexMap.Set(item.ID(), inx)
}
if l.width > 0 && l.height > 0 {
@@ -627,7 +653,7 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
newItem, ok := l.renderedItems.Get(item.ID())
if ok {
newLines := newItem.height
if len(l.items) > 1 {
if l.items.Len() > 1 {
newLines += l.gap
}
l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
@@ -649,15 +675,20 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
if !ok {
return nil
}
l.items = slices.Delete(l.items, inx, inx+1)
l.items.Delete(inx)
l.renderedItems.Del(id)
for inx, item := range l.items {
for inx, item := range l.items.Slice() {
l.indexMap.Set(item.ID(), inx)
}
if l.selectedItem == id {
if inx > 0 {
l.selectedItem = l.items[inx-1].ID()
item, ok := l.items.Get(inx - 1)
if ok {
l.selectedItem = item.ID()
} else {
l.selectedItem = ""
}
} else {
l.selectedItem = ""
}
@@ -711,7 +742,7 @@ func (l *list[T]) IsFocused() bool {
// Items implements List.
func (l *list[T]) Items() []T {
return l.items
return l.items.Slice()
}
func (l *list[T]) incrementOffset(n int) {
@@ -764,9 +795,9 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
cmds := []tea.Cmd{
item.Init(),
}
l.items = append([]T{item}, l.items...)
l.items.Prepend(item)
l.indexMap = csync.NewMap[string, int]()
for inx, item := range l.items {
for inx, item := range l.items.Slice() {
l.indexMap.Set(item.ID(), inx)
}
if l.width > 0 && l.height > 0 {
@@ -783,7 +814,7 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
newItem, ok := l.renderedItems.Get(item.ID())
if ok {
newLines := newItem.height
if len(l.items) > 1 {
if l.items.Len() > 1 {
newLines += l.gap
}
l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
@@ -817,7 +848,10 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
}
}
item := l.items[newIndex]
item, ok := l.items.Get(newIndex)
if !ok {
return nil
}
l.selectedItem = item.ID()
l.movingByItem = true
renderCmd := l.render()
@@ -839,7 +873,10 @@ func (l *list[T]) SelectItemBelow() tea.Cmd {
// no item above
return nil
}
item := l.items[newIndex]
item, ok := l.items.Get(newIndex)
if !ok {
return nil
}
l.selectedItem = item.ID()
l.movingByItem = true
return l.render()
@@ -851,18 +888,21 @@ func (l *list[T]) SelectedItem() *T {
if !ok {
return nil
}
if inx > len(l.items)-1 {
if inx > l.items.Len()-1 {
return nil
}
item, ok := l.items.Get(inx)
if !ok {
return nil
}
item := l.items[inx]
return &item
}
// SetItems implements List.
func (l *list[T]) SetItems(items []T) tea.Cmd {
l.items = items
l.items.SetSlice(items)
var cmds []tea.Cmd
for inx, item := range l.items {
for inx, item := range l.items.Slice() {
if i, ok := any(item).(Indexable); ok {
i.SetIndex(inx)
}
@@ -885,7 +925,7 @@ func (l *list[T]) reset(selectedItem string) tea.Cmd {
l.selectedItem = selectedItem
l.indexMap = csync.NewMap[string, int]()
l.renderedItems = csync.NewMap[string, renderedItem]()
for inx, item := range l.items {
for inx, item := range l.items.Slice() {
l.indexMap.Set(item.ID(), inx)
if l.width > 0 && l.height > 0 {
cmds = append(cmds, item.SetSize(l.width, l.height))
@@ -911,7 +951,7 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
var cmds []tea.Cmd
if inx, ok := l.indexMap.Get(id); ok {
l.items[inx] = item
l.items.Set(inx, item)
oldItem, hasOldItem := l.renderedItems.Get(id)
oldPosition := l.offset
if l.direction == DirectionBackward {
@@ -928,7 +968,7 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
if hasOldItem && l.direction == DirectionBackward {
// if we are the last item and there is no offset
// make sure to go to the bottom
if inx == len(l.items)-1 && l.offset == 0 {
if inx == l.items.Len()-1 && l.offset == 0 {
cmd = l.GoToBottom()
if cmd != nil {
cmds = append(cmds, cmd)

View File

@@ -30,7 +30,7 @@ func TestList(t *testing.T) {
assert.Equal(t, items[0].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 5, l.indexMap.Len())
require.Len(t, l.items, 5)
require.Equal(t, 5, l.items.Len())
require.Equal(t, 5, l.renderedItems.Len())
assert.Equal(t, 5, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
@@ -60,7 +60,7 @@ func TestList(t *testing.T) {
assert.Equal(t, items[4].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 5, l.indexMap.Len())
require.Len(t, l.items, 5)
require.Equal(t, 5, l.items.Len())
require.Equal(t, 5, l.renderedItems.Len())
assert.Equal(t, 5, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
@@ -91,7 +91,7 @@ func TestList(t *testing.T) {
assert.Equal(t, items[0].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Len(t, l.items, 30)
require.Equal(t, 30, l.items.Len())
require.Equal(t, 30, l.renderedItems.Len())
assert.Equal(t, 30, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
@@ -121,7 +121,7 @@ func TestList(t *testing.T) {
assert.Equal(t, items[29].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Len(t, l.items, 30)
require.Equal(t, 30, l.items.Len())
require.Equal(t, 30, l.renderedItems.Len())
assert.Equal(t, 30, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
@@ -154,7 +154,7 @@ func TestList(t *testing.T) {
assert.Equal(t, items[0].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Len(t, l.items, 30)
require.Equal(t, 30, l.items.Len())
require.Equal(t, 30, l.renderedItems.Len())
expectedLines := 0
for i := range 30 {
@@ -192,7 +192,7 @@ func TestList(t *testing.T) {
assert.Equal(t, items[29].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Len(t, l.items, 30)
require.Equal(t, 30, l.items.Len())
require.Equal(t, 30, l.renderedItems.Len())
expectedLines := 0
for i := range 30 {