on main help sign in
chore: safe slice
commit4614a6cdcf997c11a6dd76fa55ec47a132a2b096 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 24 Jul 2025, 22:19 (1 year ago) parentbb668b1
8 files changed+438−61
cspell.json+1−1 internal/csync/slices.go+127 internal/csync/slices_test.go+209 internal/tui/exp/list/filterable.go+1−1 internal/tui/exp/list/filterable_group.go+1−1 internal/tui/exp/list/grouped.go+3−2 internal/tui/exp/list/list.go+90−50 internal/tui/exp/list/list_test.go+6−6
cspell.json+1−1
@@ -1 +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"}
1+{"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"]}
internal/csync/slices.go+127
@@ -2,6 +2,7 @@ package csync
22
33import (
44 "iter"
5+ "slices"
56 "sync"
67)
78
@@ -34,3 +35,129 @@ func (s *LazySlice[K]) Seq() iter.Seq[K] {
3435 }
3536 }
3637}
38+
39+// Slice is a thread-safe slice implementation that provides concurrent access.
40+type Slice[T any] struct {
41+ inner []T
42+ mu sync.RWMutex
43+}
44+
45+// NewSlice creates a new thread-safe slice.
46+func NewSlice[T any]() *Slice[T] {
47+ return &Slice[T]{
48+ inner: make([]T, 0),
49+ }
50+}
51+
52+// NewSliceFrom creates a new thread-safe slice from an existing slice.
53+func NewSliceFrom[T any](s []T) *Slice[T] {
54+ inner := make([]T, len(s))
55+ copy(inner, s)
56+ return &Slice[T]{
57+ inner: inner,
58+ }
59+}
60+
61+// Append adds an element to the end of the slice.
62+func (s *Slice[T]) Append(item T) {
63+ s.mu.Lock()
64+ defer s.mu.Unlock()
65+ s.inner = append(s.inner, item)
66+}
67+
68+// Prepend adds an element to the beginning of the slice.
69+func (s *Slice[T]) Prepend(item T) {
70+ s.mu.Lock()
71+ defer s.mu.Unlock()
72+ s.inner = append([]T{item}, s.inner...)
73+}
74+
75+// Delete removes the element at the specified index.
76+func (s *Slice[T]) Delete(index int) bool {
77+ s.mu.Lock()
78+ defer s.mu.Unlock()
79+ if index < 0 || index >= len(s.inner) {
80+ return false
81+ }
82+ s.inner = slices.Delete(s.inner, index, index+1)
83+ return true
84+}
85+
86+// Get returns the element at the specified index.
87+func (s *Slice[T]) Get(index int) (T, bool) {
88+ s.mu.RLock()
89+ defer s.mu.RUnlock()
90+ var zero T
91+ if index < 0 || index >= len(s.inner) {
92+ return zero, false
93+ }
94+ return s.inner[index], true
95+}
96+
97+// Set updates the element at the specified index.
98+func (s *Slice[T]) Set(index int, item T) bool {
99+ s.mu.Lock()
100+ defer s.mu.Unlock()
101+ if index < 0 || index >= len(s.inner) {
102+ return false
103+ }
104+ s.inner[index] = item
105+ return true
106+}
107+
108+// Len returns the number of elements in the slice.
109+func (s *Slice[T]) Len() int {
110+ s.mu.RLock()
111+ defer s.mu.RUnlock()
112+ return len(s.inner)
113+}
114+
115+// Slice returns a copy of the underlying slice.
116+func (s *Slice[T]) Slice() []T {
117+ s.mu.RLock()
118+ defer s.mu.RUnlock()
119+ result := make([]T, len(s.inner))
120+ copy(result, s.inner)
121+ return result
122+}
123+
124+// SetSlice replaces the entire slice with a new one.
125+func (s *Slice[T]) SetSlice(items []T) {
126+ s.mu.Lock()
127+ defer s.mu.Unlock()
128+ s.inner = make([]T, len(items))
129+ copy(s.inner, items)
130+}
131+
132+// Clear removes all elements from the slice.
133+func (s *Slice[T]) Clear() {
134+ s.mu.Lock()
135+ defer s.mu.Unlock()
136+ s.inner = s.inner[:0]
137+}
138+
139+// Seq returns an iterator that yields elements from the slice.
140+func (s *Slice[T]) Seq() iter.Seq[T] {
141+ // Take a snapshot to avoid holding the lock during iteration
142+ items := s.Slice()
143+ return func(yield func(T) bool) {
144+ for _, v := range items {
145+ if !yield(v) {
146+ return
147+ }
148+ }
149+ }
150+}
151+
152+// SeqWithIndex returns an iterator that yields index-value pairs from the slice.
153+func (s *Slice[T]) SeqWithIndex() iter.Seq2[int, T] {
154+ // Take a snapshot to avoid holding the lock during iteration
155+ items := s.Slice()
156+ return func(yield func(int, T) bool) {
157+ for i, v := range items {
158+ if !yield(i, v) {
159+ return
160+ }
161+ }
162+ }
163+}
internal/csync/slices_test.go+209
@@ -1,11 +1,13 @@
11package csync
22
33import (
4+ "sync"
45 "sync/atomic"
56 "testing"
67 "time"
78
89 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
911)
1012
1113func TestLazySlice_Seq(t *testing.T) {
@@ -85,3 +87,210 @@ func TestLazySlice_EarlyBreak(t *testing.T) {
8587
8688 assert.Equal(t, []string{"a", "b"}, result)
8789}
90+
91+func TestSlice(t *testing.T) {
92+ t.Run("NewSlice", func(t *testing.T) {
93+ s := NewSlice[int]()
94+ assert.Equal(t, 0, s.Len())
95+ })
96+
97+ t.Run("NewSliceFrom", func(t *testing.T) {
98+ original := []int{1, 2, 3}
99+ s := NewSliceFrom(original)
100+ assert.Equal(t, 3, s.Len())
101+
102+ // Verify it's a copy, not a reference
103+ original[0] = 999
104+ val, ok := s.Get(0)
105+ require.True(t, ok)
106+ assert.Equal(t, 1, val)
107+ })
108+
109+ t.Run("Append", func(t *testing.T) {
110+ s := NewSlice[string]()
111+ s.Append("hello")
112+ s.Append("world")
113+
114+ assert.Equal(t, 2, s.Len())
115+ val, ok := s.Get(0)
116+ require.True(t, ok)
117+ assert.Equal(t, "hello", val)
118+
119+ val, ok = s.Get(1)
120+ require.True(t, ok)
121+ assert.Equal(t, "world", val)
122+ })
123+
124+ t.Run("Prepend", func(t *testing.T) {
125+ s := NewSlice[string]()
126+ s.Append("world")
127+ s.Prepend("hello")
128+
129+ assert.Equal(t, 2, s.Len())
130+ val, ok := s.Get(0)
131+ require.True(t, ok)
132+ assert.Equal(t, "hello", val)
133+
134+ val, ok = s.Get(1)
135+ require.True(t, ok)
136+ assert.Equal(t, "world", val)
137+ })
138+
139+ t.Run("Delete", func(t *testing.T) {
140+ s := NewSliceFrom([]int{1, 2, 3, 4, 5})
141+
142+ // Delete middle element
143+ ok := s.Delete(2)
144+ assert.True(t, ok)
145+ assert.Equal(t, 4, s.Len())
146+
147+ expected := []int{1, 2, 4, 5}
148+ actual := s.Slice()
149+ assert.Equal(t, expected, actual)
150+
151+ // Delete out of bounds
152+ ok = s.Delete(10)
153+ assert.False(t, ok)
154+ assert.Equal(t, 4, s.Len())
155+
156+ // Delete negative index
157+ ok = s.Delete(-1)
158+ assert.False(t, ok)
159+ assert.Equal(t, 4, s.Len())
160+ })
161+
162+ t.Run("Get", func(t *testing.T) {
163+ s := NewSliceFrom([]string{"a", "b", "c"})
164+
165+ val, ok := s.Get(1)
166+ require.True(t, ok)
167+ assert.Equal(t, "b", val)
168+
169+ // Out of bounds
170+ _, ok = s.Get(10)
171+ assert.False(t, ok)
172+
173+ // Negative index
174+ _, ok = s.Get(-1)
175+ assert.False(t, ok)
176+ })
177+
178+ t.Run("Set", func(t *testing.T) {
179+ s := NewSliceFrom([]string{"a", "b", "c"})
180+
181+ ok := s.Set(1, "modified")
182+ assert.True(t, ok)
183+
184+ val, ok := s.Get(1)
185+ require.True(t, ok)
186+ assert.Equal(t, "modified", val)
187+
188+ // Out of bounds
189+ ok = s.Set(10, "invalid")
190+ assert.False(t, ok)
191+
192+ // Negative index
193+ ok = s.Set(-1, "invalid")
194+ assert.False(t, ok)
195+ })
196+
197+ t.Run("SetSlice", func(t *testing.T) {
198+ s := NewSlice[int]()
199+ s.Append(1)
200+ s.Append(2)
201+
202+ newItems := []int{10, 20, 30}
203+ s.SetSlice(newItems)
204+
205+ assert.Equal(t, 3, s.Len())
206+ assert.Equal(t, newItems, s.Slice())
207+
208+ // Verify it's a copy
209+ newItems[0] = 999
210+ val, ok := s.Get(0)
211+ require.True(t, ok)
212+ assert.Equal(t, 10, val)
213+ })
214+
215+ t.Run("Clear", func(t *testing.T) {
216+ s := NewSliceFrom([]int{1, 2, 3})
217+ assert.Equal(t, 3, s.Len())
218+
219+ s.Clear()
220+ assert.Equal(t, 0, s.Len())
221+ })
222+
223+ t.Run("Slice", func(t *testing.T) {
224+ original := []int{1, 2, 3}
225+ s := NewSliceFrom(original)
226+
227+ copy := s.Slice()
228+ assert.Equal(t, original, copy)
229+
230+ // Verify it's a copy
231+ copy[0] = 999
232+ val, ok := s.Get(0)
233+ require.True(t, ok)
234+ assert.Equal(t, 1, val)
235+ })
236+
237+ t.Run("Seq", func(t *testing.T) {
238+ s := NewSliceFrom([]int{1, 2, 3})
239+
240+ var result []int
241+ for v := range s.Seq() {
242+ result = append(result, v)
243+ }
244+
245+ assert.Equal(t, []int{1, 2, 3}, result)
246+ })
247+
248+ t.Run("SeqWithIndex", func(t *testing.T) {
249+ s := NewSliceFrom([]string{"a", "b", "c"})
250+
251+ var indices []int
252+ var values []string
253+ for i, v := range s.SeqWithIndex() {
254+ indices = append(indices, i)
255+ values = append(values, v)
256+ }
257+
258+ assert.Equal(t, []int{0, 1, 2}, indices)
259+ assert.Equal(t, []string{"a", "b", "c"}, values)
260+ })
261+
262+ t.Run("ConcurrentAccess", func(t *testing.T) {
263+ s := NewSlice[int]()
264+ const numGoroutines = 100
265+ const itemsPerGoroutine = 10
266+
267+ var wg sync.WaitGroup
268+
269+ // Concurrent appends
270+ for i := 0; i < numGoroutines; i++ {
271+ wg.Add(1)
272+ go func(start int) {
273+ defer wg.Done()
274+ for j := 0; j < itemsPerGoroutine; j++ {
275+ s.Append(start*itemsPerGoroutine + j)
276+ }
277+ }(i)
278+ }
279+
280+ // Concurrent reads
281+ for i := 0; i < numGoroutines; i++ {
282+ wg.Add(1)
283+ go func() {
284+ defer wg.Done()
285+ for j := 0; j < itemsPerGoroutine; j++ {
286+ s.Len() // Just read the length
287+ }
288+ }()
289+ }
290+
291+ wg.Wait()
292+
293+ // Should have all items
294+ assert.Equal(t, numGoroutines*itemsPerGoroutine, s.Len())
295+ })
296+}
internal/tui/exp/list/filterable.go+1−1
@@ -97,7 +97,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
9797 f.list = New[T](items, f.listOptions...).(*list[T])
9898
9999 f.updateKeyMaps()
100− f.items = f.list.items
100+ f.items = f.list.items.Slice()
101101
102102 if f.inputHidden {
103103 return f
internal/tui/exp/list/filterable_group.go+1−1
@@ -179,7 +179,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
179179
180180func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
181181 var cmds []tea.Cmd
182− for _, item := range f.items {
182+ for _, item := range f.items.Slice() {
183183 if i, ok := any(item).(layout.Focusable); ok {
184184 cmds = append(cmds, i.Blur())
185185 }
internal/tui/exp/list/grouped.go+3−2
@@ -38,6 +38,7 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T
3838 keyMap: DefaultKeyMap(),
3939 focused: true,
4040 },
41+ items: csync.NewSlice[Item](),
4142 indexMap: csync.NewMap[string, int](),
4243 renderedItems: csync.NewMap[string, renderedItem](),
4344 }
@@ -82,13 +83,13 @@ func (g *groupedList[T]) convertItems() {
8283 items = append(items, g)
8384 }
8485 }
85− g.items = items
86+ g.items.SetSlice(items)
8687}
8788
8889func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
8990 g.groups = groups
9091 g.convertItems()
91− return g.SetItems(g.items)
92+ return g.SetItems(g.items.Slice())
9293}
9394
9495func (g *groupedList[T]) Groups() []Group[T] {
internal/tui/exp/list/list.go+90−50
@@ -1,7 +1,6 @@
11package list
22
33import (
4− "slices"
54 "strings"
65
76 "github.com/charmbracelet/bubbles/v2/key"
@@ -86,7 +85,7 @@ type list[T Item] struct {
8685 offset int
8786
8887 indexMap *csync.Map[string, int]
89− items []T
88+ items *csync.Slice[T]
9089
9190 renderedItems *csync.Map[string, renderedItem]
9291
@@ -164,7 +163,7 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
164163 keyMap: DefaultKeyMap(),
165164 focused: true,
166165 },
167− items: items,
166+ items: csync.NewSliceFrom(items),
168167 indexMap: csync.NewMap[string, int](),
169168 renderedItems: csync.NewMap[string, renderedItem](),
170169 }
@@ -191,7 +190,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
191190 switch msg := msg.(type) {
192191 case anim.StepMsg:
193192 var cmds []tea.Cmd
194− for _, item := range l.items {
193+ for _, item := range l.items.Slice() {
195194 if i, ok := any(item).(HasAnim); ok && i.Spinning() {
196195 updated, cmd := i.Update(msg)
197196 cmds = append(cmds, cmd)
@@ -267,7 +266,7 @@ func (l *list[T]) viewPosition() (int, int) {
267266
268267func (l *list[T]) recalculateItemPositions() {
269268 currentContentHeight := 0
270− for _, item := range l.items {
269+ for _, item := range l.items.Slice() {
271270 rItem, ok := l.renderedItems.Get(item.ID())
272271 if !ok {
273272 continue
@@ -280,7 +279,7 @@ func (l *list[T]) recalculateItemPositions() {
280279}
281280
282281func (l *list[T]) render() tea.Cmd {
283− if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
282+ if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
284283 return nil
285284 }
286285 l.setDefaultSelected()
@@ -424,19 +423,23 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
424423 if inx == ItemNotFound {
425424 return nil
426425 }
427− item, ok := l.renderedItems.Get(l.items[inx].ID())
426+ item, ok := l.items.Get(inx)
427+ if !ok {
428+ continue
429+ }
430+ renderedItem, ok := l.renderedItems.Get(item.ID())
428431 if !ok {
429432 continue
430433 }
431434
432435 // If the item is bigger than the viewport, select it
433− if item.start <= start && item.end >= end {
434− l.selectedItem = item.id
436+ if renderedItem.start <= start && renderedItem.end >= end {
437+ l.selectedItem = renderedItem.id
435438 return l.render()
436439 }
437440 // item is in the view
438− if item.start >= start && item.start <= end {
439− l.selectedItem = item.id
441+ if renderedItem.start >= start && renderedItem.start <= end {
442+ l.selectedItem = renderedItem.id
440443 return l.render()
441444 }
442445 }
@@ -452,19 +455,23 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
452455 if inx == ItemNotFound {
453456 return nil
454457 }
455− item, ok := l.renderedItems.Get(l.items[inx].ID())
458+ item, ok := l.items.Get(inx)
459+ if !ok {
460+ continue
461+ }
462+ renderedItem, ok := l.renderedItems.Get(item.ID())
456463 if !ok {
457464 continue
458465 }
459466
460467 // If the item is bigger than the viewport, select it
461− if item.start <= start && item.end >= end {
462− l.selectedItem = item.id
468+ if renderedItem.start <= start && renderedItem.end >= end {
469+ l.selectedItem = renderedItem.id
463470 return l.render()
464471 }
465472 // item is in the view
466− if item.end >= start && item.end <= end {
467− l.selectedItem = item.id
473+ if renderedItem.end >= start && renderedItem.end <= end {
474+ l.selectedItem = renderedItem.id
468475 return l.render()
469476 }
470477 }
@@ -475,36 +482,51 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
475482func (l *list[T]) selectFirstItem() {
476483 inx := l.firstSelectableItemBelow(-1)
477484 if inx != ItemNotFound {
478− l.selectedItem = l.items[inx].ID()
485+ item, ok := l.items.Get(inx)
486+ if ok {
487+ l.selectedItem = item.ID()
488+ }
479489 }
480490}
481491
482492func (l *list[T]) selectLastItem() {
483− inx := l.firstSelectableItemAbove(len(l.items))
493+ inx := l.firstSelectableItemAbove(l.items.Len())
484494 if inx != ItemNotFound {
485− l.selectedItem = l.items[inx].ID()
495+ item, ok := l.items.Get(inx)
496+ if ok {
497+ l.selectedItem = item.ID()
498+ }
486499 }
487500}
488501
489502func (l *list[T]) firstSelectableItemAbove(inx int) int {
490503 for i := inx - 1; i >= 0; i-- {
491− if _, ok := any(l.items[i]).(layout.Focusable); ok {
504+ item, ok := l.items.Get(i)
505+ if !ok {
506+ continue
507+ }
508+ if _, ok := any(item).(layout.Focusable); ok {
492509 return i
493510 }
494511 }
495512 if inx == 0 && l.wrap {
496− return l.firstSelectableItemAbove(len(l.items))
513+ return l.firstSelectableItemAbove(l.items.Len())
497514 }
498515 return ItemNotFound
499516}
500517
501518func (l *list[T]) firstSelectableItemBelow(inx int) int {
502− for i := inx + 1; i < len(l.items); i++ {
503− if _, ok := any(l.items[i]).(layout.Focusable); ok {
519+ itemsLen := l.items.Len()
520+ for i := inx + 1; i < itemsLen; i++ {
521+ item, ok := l.items.Get(i)
522+ if !ok {
523+ continue
524+ }
525+ if _, ok := any(item).(layout.Focusable); ok {
504526 return i
505527 }
506528 }
507− if inx == len(l.items)-1 && l.wrap {
529+ if inx == itemsLen-1 && l.wrap {
508530 return l.firstSelectableItemBelow(-1)
509531 }
510532 return ItemNotFound
@@ -515,7 +537,7 @@ func (l *list[T]) focusSelectedItem() tea.Cmd {
515537 return nil
516538 }
517539 var cmds []tea.Cmd
518− for _, item := range l.items {
540+ for _, item := range l.items.Slice() {
519541 if f, ok := any(item).(layout.Focusable); ok {
520542 if item.ID() == l.selectedItem && !f.IsFocused() {
521543 cmds = append(cmds, f.Focus())
@@ -534,7 +556,7 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
534556 return nil
535557 }
536558 var cmds []tea.Cmd
537− for _, item := range l.items {
559+ for _, item := range l.items.Slice() {
538560 if f, ok := any(item).(layout.Focusable); ok {
539561 if item.ID() == l.selectedItem && f.IsFocused() {
540562 cmds = append(cmds, f.Blur())
@@ -549,7 +571,8 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
549571// returns the last index
550572func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
551573 currentContentHeight := lipgloss.Height(l.rendered) - 1
552− for i := startInx; i < len(l.items); i++ {
574+ itemsLen := l.items.Len()
575+ for i := startInx; i < itemsLen; i++ {
553576 if currentContentHeight >= l.height && limitHeight {
554577 return i
555578 }
@@ -557,10 +580,13 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
557580 inx := i
558581
559582 if l.direction != DirectionForward {
560− inx = (len(l.items) - 1) - i
583+ inx = (itemsLen - 1) - i
561584 }
562585
563− item := l.items[inx]
586+ item, ok := l.items.Get(inx)
587+ if !ok {
588+ continue
589+ }
564590 var rItem renderedItem
565591 if cache, ok := l.renderedItems.Get(item.ID()); ok {
566592 rItem = cache
@@ -571,7 +597,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
571597 l.renderedItems.Set(item.ID(), rItem)
572598 }
573599 gap := l.gap + 1
574− if inx == len(l.items)-1 {
600+ if inx == itemsLen-1 {
575601 gap = 0
576602 }
577603
@@ -582,7 +608,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
582608 }
583609 currentContentHeight = rItem.end + 1 + l.gap
584610 }
585− return len(l.items)
611+ return itemsLen
586612}
587613
588614func (l *list[T]) renderItem(item Item) renderedItem {
@@ -602,9 +628,9 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
602628 cmds = append(cmds, cmd)
603629 }
604630
605− l.items = append(l.items, item)
631+ l.items.Append(item)
606632 l.indexMap = csync.NewMap[string, int]()
607− for inx, item := range l.items {
633+ for inx, item := range l.items.Slice() {
608634 l.indexMap.Set(item.ID(), inx)
609635 }
610636 if l.width > 0 && l.height > 0 {
@@ -627,7 +653,7 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
627653 newItem, ok := l.renderedItems.Get(item.ID())
628654 if ok {
629655 newLines := newItem.height
630− if len(l.items) > 1 {
656+ if l.items.Len() > 1 {
631657 newLines += l.gap
632658 }
633659 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
@@ -649,15 +675,20 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
649675 if !ok {
650676 return nil
651677 }
652− l.items = slices.Delete(l.items, inx, inx+1)
678+ l.items.Delete(inx)
653679 l.renderedItems.Del(id)
654− for inx, item := range l.items {
680+ for inx, item := range l.items.Slice() {
655681 l.indexMap.Set(item.ID(), inx)
656682 }
657683
658684 if l.selectedItem == id {
659685 if inx > 0 {
660− l.selectedItem = l.items[inx-1].ID()
686+ item, ok := l.items.Get(inx - 1)
687+ if ok {
688+ l.selectedItem = item.ID()
689+ } else {
690+ l.selectedItem = ""
691+ }
661692 } else {
662693 l.selectedItem = ""
663694 }
@@ -711,7 +742,7 @@ func (l *list[T]) IsFocused() bool {
711742
712743// Items implements List.
713744func (l *list[T]) Items() []T {
714− return l.items
745+ return l.items.Slice()
715746}
716747
717748func (l *list[T]) incrementOffset(n int) {
@@ -764,9 +795,9 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
764795 cmds := []tea.Cmd{
765796 item.Init(),
766797 }
767− l.items = append([]T{item}, l.items...)
798+ l.items.Prepend(item)
768799 l.indexMap = csync.NewMap[string, int]()
769− for inx, item := range l.items {
800+ for inx, item := range l.items.Slice() {
770801 l.indexMap.Set(item.ID(), inx)
771802 }
772803 if l.width > 0 && l.height > 0 {
@@ -783,7 +814,7 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
783814 newItem, ok := l.renderedItems.Get(item.ID())
784815 if ok {
785816 newLines := newItem.height
786− if len(l.items) > 1 {
817+ if l.items.Len() > 1 {
787818 newLines += l.gap
788819 }
789820 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
@@ -817,7 +848,10 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
817848 }
818849
819850 }
820− item := l.items[newIndex]
851+ item, ok := l.items.Get(newIndex)
852+ if !ok {
853+ return nil
854+ }
821855 l.selectedItem = item.ID()
822856 l.movingByItem = true
823857 renderCmd := l.render()
@@ -839,7 +873,10 @@ func (l *list[T]) SelectItemBelow() tea.Cmd {
839873 // no item above
840874 return nil
841875 }
842− item := l.items[newIndex]
876+ item, ok := l.items.Get(newIndex)
877+ if !ok {
878+ return nil
879+ }
843880 l.selectedItem = item.ID()
844881 l.movingByItem = true
845882 return l.render()
@@ -851,18 +888,21 @@ func (l *list[T]) SelectedItem() *T {
851888 if !ok {
852889 return nil
853890 }
854− if inx > len(l.items)-1 {
891+ if inx > l.items.Len()-1 {
892+ return nil
893+ }
894+ item, ok := l.items.Get(inx)
895+ if !ok {
855896 return nil
856897 }
857− item := l.items[inx]
858898 return &item
859899}
860900
861901// SetItems implements List.
862902func (l *list[T]) SetItems(items []T) tea.Cmd {
863− l.items = items
903+ l.items.SetSlice(items)
864904 var cmds []tea.Cmd
865− for inx, item := range l.items {
905+ for inx, item := range l.items.Slice() {
866906 if i, ok := any(item).(Indexable); ok {
867907 i.SetIndex(inx)
868908 }
@@ -885,7 +925,7 @@ func (l *list[T]) reset(selectedItem string) tea.Cmd {
885925 l.selectedItem = selectedItem
886926 l.indexMap = csync.NewMap[string, int]()
887927 l.renderedItems = csync.NewMap[string, renderedItem]()
888− for inx, item := range l.items {
928+ for inx, item := range l.items.Slice() {
889929 l.indexMap.Set(item.ID(), inx)
890930 if l.width > 0 && l.height > 0 {
891931 cmds = append(cmds, item.SetSize(l.width, l.height))
@@ -911,7 +951,7 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
911951func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
912952 var cmds []tea.Cmd
913953 if inx, ok := l.indexMap.Get(id); ok {
914− l.items[inx] = item
954+ l.items.Set(inx, item)
915955 oldItem, hasOldItem := l.renderedItems.Get(id)
916956 oldPosition := l.offset
917957 if l.direction == DirectionBackward {
@@ -928,7 +968,7 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
928968 if hasOldItem && l.direction == DirectionBackward {
929969 // if we are the last item and there is no offset
930970 // make sure to go to the bottom
931− if inx == len(l.items)-1 && l.offset == 0 {
971+ if inx == l.items.Len()-1 && l.offset == 0 {
932972 cmd = l.GoToBottom()
933973 if cmd != nil {
934974 cmds = append(cmds, cmd)
internal/tui/exp/list/list_test.go+6−6
@@ -30,7 +30,7 @@ func TestList(t *testing.T) {
3030 assert.Equal(t, items[0].ID(), l.selectedItem)
3131 assert.Equal(t, 0, l.offset)
3232 require.Equal(t, 5, l.indexMap.Len())
33− require.Len(t, l.items, 5)
33+ require.Equal(t, 5, l.items.Len())
3434 require.Equal(t, 5, l.renderedItems.Len())
3535 assert.Equal(t, 5, lipgloss.Height(l.rendered))
3636 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) {
6060 assert.Equal(t, items[4].ID(), l.selectedItem)
6161 assert.Equal(t, 0, l.offset)
6262 require.Equal(t, 5, l.indexMap.Len())
63− require.Len(t, l.items, 5)
63+ require.Equal(t, 5, l.items.Len())
6464 require.Equal(t, 5, l.renderedItems.Len())
6565 assert.Equal(t, 5, lipgloss.Height(l.rendered))
6666 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) {
9191 assert.Equal(t, items[0].ID(), l.selectedItem)
9292 assert.Equal(t, 0, l.offset)
9393 require.Equal(t, 30, l.indexMap.Len())
94− require.Len(t, l.items, 30)
94+ require.Equal(t, 30, l.items.Len())
9595 require.Equal(t, 30, l.renderedItems.Len())
9696 assert.Equal(t, 30, lipgloss.Height(l.rendered))
9797 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) {
121121 assert.Equal(t, items[29].ID(), l.selectedItem)
122122 assert.Equal(t, 0, l.offset)
123123 require.Equal(t, 30, l.indexMap.Len())
124− require.Len(t, l.items, 30)
124+ require.Equal(t, 30, l.items.Len())
125125 require.Equal(t, 30, l.renderedItems.Len())
126126 assert.Equal(t, 30, lipgloss.Height(l.rendered))
127127 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) {
154154 assert.Equal(t, items[0].ID(), l.selectedItem)
155155 assert.Equal(t, 0, l.offset)
156156 require.Equal(t, 30, l.indexMap.Len())
157− require.Len(t, l.items, 30)
157+ require.Equal(t, 30, l.items.Len())
158158 require.Equal(t, 30, l.renderedItems.Len())
159159 expectedLines := 0
160160 for i := range 30 {
@@ -192,7 +192,7 @@ func TestList(t *testing.T) {
192192 assert.Equal(t, items[29].ID(), l.selectedItem)
193193 assert.Equal(t, 0, l.offset)
194194 require.Equal(t, 30, l.indexMap.Len())
195− require.Len(t, l.items, 30)
195+ require.Equal(t, 30, l.items.Len())
196196 require.Equal(t, 30, l.renderedItems.Len())
197197 expectedLines := 0
198198 for i := range 30 {