diff --git a/go.mod b/go.mod index f7c8f4d1c11ea2bd7bcc747c94ddab224a63a7bf..dc9619215f26ec70569ce52e6aca7a052468f4b0 100644 --- a/go.mod +++ b/go.mod @@ -37,11 +37,11 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.10.0 + github.com/gammazero/deque v1.0.0 mvdan.cc/sh/v3 v3.11.0 ) require ( - golang.org/x/term v0.31.0 // indirect cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect @@ -132,6 +132,7 @@ require ( golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect google.golang.org/genai v1.3.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect diff --git a/go.sum b/go.sum index 56284da1290cd092f24115a9a71084564d62284d..0e2abd94459cd47487e8432c1ef6f26b9d0ad3ea 100644 --- a/go.sum +++ b/go.sum @@ -76,11 +76,11 @@ github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg= github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc= -github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY= +github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:04xT769flyN8f2oXy0C2ol0aXVJKlbQYQ/fJNgrsF58= github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk= github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY= github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ= -github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ= +github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:uwKExR2sAgLtFdP28sPca/kgiZNcQG6+7voM5pjedT0= github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= @@ -116,6 +116,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34= +github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 3ad3f68dffd46960bcfb5f969991f25218494a2c..f864c3f5bd732e0a14d5564f00f95b8b3c8546a4 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" + "github.com/gammazero/deque" "github.com/sahilm/fuzzy" ) @@ -126,15 +127,15 @@ func (ss *selectionState) isValidIndex(itemCount int) bool { // model is the main implementation of the ListModel interface. // It coordinates between view state, render state, and selection state. type model struct { - viewState viewState // Display and scrolling state - renderState *renderState // Rendering cache and state - selectionState selectionState // Item selection state - help help.Model // Help system for keyboard shortcuts - keyMap KeyMap // Key bindings for navigation - allItems []util.Model // The actual list items - gapSize int // Number of empty lines between items - padding []int // Padding around the list content - wrapNavigation bool // Whether to wrap navigation at the ends + viewState viewState // Display and scrolling state + renderState *renderState // Rendering cache and state + selectionState selectionState // Item selection state + help help.Model // Help system for keyboard shortcuts + keyMap KeyMap // Key bindings for navigation + allItems *deque.Deque[util.Model] // Item list using deque for efficient prepend/append + gapSize int // Number of empty lines between items + padding []int // Padding around the list content + wrapNavigation bool // Whether to wrap navigation at the ends filterable bool // Whether items can be filtered filterPlaceholder string // Placeholder text for filter input @@ -183,7 +184,10 @@ func WithPadding(padding ...int) listOptions { // WithItems sets the initial items for the list. func WithItems(items []util.Model) listOptions { return func(m *model) { - m.allItems = items + m.allItems.Clear() + for _, item := range items { + m.allItems.PushBack(item) + } m.filteredItems = items // Initially, all items are visible } } @@ -232,7 +236,7 @@ func New(opts ...listOptions) ListModel { m := &model{ help: help.New(), keyMap: DefaultKeyMap(), - allItems: []util.Model{}, + allItems: &deque.Deque[util.Model]{}, filteredItems: []util.Model{}, renderState: newRenderState(), gapSize: DefaultGapSize, @@ -258,6 +262,18 @@ func New(opts ...listOptions) ListModel { return m } +// allItemsSlice converts the deque to a slice for compatibility with existing code. +func (m *model) allItemsSlice() []util.Model { + if m.allItems.Len() == 0 { + return nil + } + result := make([]util.Model, m.allItems.Len()) + for i := 0; i < m.allItems.Len(); i++ { + result[i] = m.allItems.At(i) + } + return result +} + // Init initializes the list component and sets up the initial items. // This is called automatically by the Bubble Tea framework. func (m *model) Init() tea.Cmd { @@ -1052,8 +1068,8 @@ func (m *model) AppendItem(item util.Model) tea.Cmd { cmds := []tea.Cmd{ item.Init(), } - m.allItems = append(m.allItems, item) - m.filteredItems = m.allItems + m.allItems.PushBack(item) + m.filteredItems = m.allItemsSlice() cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1)) cmds = append(cmds, m.goToBottom()) m.renderState.needsRerender = true @@ -1063,12 +1079,12 @@ func (m *model) AppendItem(item util.Model) tea.Cmd { // DeleteItem removes an item at the specified index. // Adjusts selection if necessary and triggers a complete re-render. func (m *model) DeleteItem(i int) { - if i < 0 || i >= len(m.filteredItems) { + if i < 0 || i >= m.allItems.Len() { return } - m.allItems = slices.Delete(m.allItems, i, i+1) + m.allItems.Remove(i) delete(m.renderState.items, i) - m.filteredItems = m.allItems + m.filteredItems = m.allItemsSlice() if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 { m.selectionState.selectedIndex-- @@ -1084,8 +1100,8 @@ func (m *model) DeleteItem(i int) { // Adjusts cached positions and selection index, then switches to forward mode. func (m *model) PrependItem(item util.Model) tea.Cmd { cmds := []tea.Cmd{item.Init()} - m.allItems = append([]util.Model{item}, m.allItems...) - m.filteredItems = m.allItems + m.allItems.PushFront(item) + m.filteredItems = m.allItemsSlice() // Shift all cached item indices by 1 newItems := make(map[int]renderedItem, len(m.renderState.items)) @@ -1117,7 +1133,10 @@ func (m *model) setReverse(reverse bool) { // Initializes all items, sets their sizes, and establishes initial selection. // Ensures the initial selection skips section headers. func (m *model) SetItems(items []util.Model) tea.Cmd { - m.allItems = items + m.allItems.Clear() + for _, item := range items { + m.allItems.PushBack(item) + } m.filteredItems = items cmds := []tea.Cmd{m.setAllItemsSize()} @@ -1153,7 +1172,8 @@ func (m *model) parseSections() []section { var sections []section var currentSection *section - for _, item := range m.allItems { + for i := 0; i < m.allItems.Len(); i++ { + item := m.allItems.At(i) if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() { // Start a new section if currentSection != nil { @@ -1208,17 +1228,18 @@ func (m *model) Filter(search string) tea.Cmd { search = strings.ToLower(search) // Clear focus and match indexes from all items - for _, item := range m.allItems { - if i, ok := item.(layout.Focusable); ok { - cmds = append(cmds, i.Blur()) + for i := 0; i < m.allItems.Len(); i++ { + item := m.allItems.At(i) + if focusable, ok := item.(layout.Focusable); ok { + cmds = append(cmds, focusable.Blur()) } - if i, ok := item.(HasMatchIndexes); ok { - i.MatchIndexes(make([]int, 0)) + if hasMatch, ok := item.(HasMatchIndexes); ok { + hasMatch.MatchIndexes(make([]int, 0)) } } if search == "" { - cmds = append(cmds, m.SetItems(m.allItems)) + cmds = append(cmds, m.SetItems(m.allItemsSlice())) return tea.Batch(cmds...) }