From 4614a6cdcf997c11a6dd76fa55ec47a132a2b096 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 22:19:01 +0200 Subject: [PATCH] chore: safe slice --- cspell.json | 2 +- internal/csync/slices.go | 127 +++++++++++++ internal/csync/slices_test.go | 209 ++++++++++++++++++++++ internal/tui/exp/list/filterable.go | 2 +- internal/tui/exp/list/filterable_group.go | 2 +- internal/tui/exp/list/grouped.go | 5 +- internal/tui/exp/list/list.go | 140 +++++++++------ internal/tui/exp/list/list_test.go | 12 +- 8 files changed, 438 insertions(+), 61 deletions(-) diff --git a/cspell.json b/cspell.json index 797efddbfc2ba8dbbb8b121f4192f2449b2025ae..713684deb4cf3f066d92b6a71a063df90cddf0fc 100644 --- a/cspell.json +++ b/cspell.json @@ -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"} \ No newline at end of file +{"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"]} \ No newline at end of file diff --git a/internal/csync/slices.go b/internal/csync/slices.go index be723655079ccc6b07f55c3237b706a17bb14d40..4e67ac1a127f577d1d13673e4311b2ea44e17393 100644 --- a/internal/csync/slices.go +++ b/internal/csync/slices.go @@ -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 + } + } + } +} diff --git a/internal/csync/slices_test.go b/internal/csync/slices_test.go index 731cb96f55dd24cae74f55c0ef8e97ebd28aacaa..d838485e4865210d232c89e1531a191ce7d09418 100644 --- a/internal/csync/slices_test.go +++ b/internal/csync/slices_test.go @@ -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()) + }) +} diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index 3fbd1175ea634a21ae52b3e8ccd96e663206347d..89930a905b162a7b470ab01588013da9577bf81c 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -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 diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go index c1b885da00f02529cb54dcc4505afe1f34807e38..2543723b4b74072510c31453441495d27ec05c9d 100644 --- a/internal/tui/exp/list/filterable_group.go +++ b/internal/tui/exp/list/filterable_group.go @@ -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()) } diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go index 26c3993e717830dfbfb3b7d3b4a3af247bcc7a43..4b9124fdd0a0e3335d3b22ef122dc5cd86c5785b 100644 --- a/internal/tui/exp/list/grouped.go +++ b/internal/tui/exp/list/grouped.go @@ -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] { diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index e4c44c4bde1606300329df31b5bb1486d9301831..c3fec3000f86b0edac3debcc55644f1c10e11662 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -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) diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index ff824e450fad501dff3c8431f77cb845dd0e17d3..63cfa599e8ce1c96aad1cae67243caa2b097ee0b 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -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 {