Merge branch 'list' into tool_improvements

This commit is contained in:
Kujtim Hoxha
2025-07-26 12:51:28 +02:00
22 changed files with 226 additions and 109 deletions

View File

@@ -110,6 +110,7 @@ homebrew_casks:
- repository:
owner: charmbracelet
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
npms:
- name: "@charmland/crush"
@@ -134,6 +135,32 @@ nfpms:
- src: ./manpages/crush.1.gz
dst: /usr/share/man/man1/crush.1.gz
nix:
- repository:
owner: "charmbracelet"
name: nur
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
extra_install: |-
installManPage ./manpages/crush.1.gz.
installShellCompletion ./completions/*
winget:
- publisher: charmbracelet
copyright: Charmbracelet, Inc
repository:
owner: "charmbracelet"
name: winget-pkgs
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
branch: "crush-{{.Version}}"
pull_request:
enabled: true
draft: false
check_boxes: true
base:
owner: microsoft
name: winget-pkgs
branch: master
changelog:
sort: asc
disable: "{{ .IsNightly }}"

View File

@@ -17,7 +17,7 @@ import (
"github.com/charmbracelet/crush/internal/log"
)
const catwalkURL = "https://catwalk.charm.sh"
const defaultCatwalkURL = "https://catwalk.charm.sh"
// LoadReader config via io.Reader.
func LoadReader(fd io.Reader) (*Config, error) {

View File

@@ -1,6 +1,7 @@
package config
import (
"cmp"
"encoding/json"
"fmt"
"log/slog"
@@ -74,6 +75,7 @@ func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
}
func Providers() ([]catwalk.Provider, error) {
catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
client := catwalk.NewWithURL(catwalkURL)
path := providerCacheFileData()
return loadProvidersOnce(client, path)

View File

@@ -112,15 +112,6 @@ func (s *Slice[T]) Len() int {
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()
@@ -138,10 +129,8 @@ func (s *Slice[T]) Clear() {
// 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 {
for _, v := range s.Seq2() {
if !yield(v) {
return
}
@@ -151,8 +140,10 @@ func (s *Slice[T]) Seq() iter.Seq[T] {
// Seq2 returns an iterator that yields index-value pairs from the slice.
func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
// Take a snapshot to avoid holding the lock during iteration
items := s.Slice()
s.mu.RLock()
items := make([]T, len(s.inner))
copy(items, s.inner)
s.mu.RUnlock()
return func(yield func(int, T) bool) {
for i, v := range items {
if !yield(i, v) {

View File

@@ -1,6 +1,7 @@
package csync
import (
"slices"
"sync"
"sync/atomic"
"testing"
@@ -145,7 +146,7 @@ func TestSlice(t *testing.T) {
assert.Equal(t, 4, s.Len())
expected := []int{1, 2, 4, 5}
actual := s.Slice()
actual := slices.Collect(s.Seq())
assert.Equal(t, expected, actual)
// Delete out of bounds
@@ -203,7 +204,7 @@ func TestSlice(t *testing.T) {
s.SetSlice(newItems)
assert.Equal(t, 3, s.Len())
assert.Equal(t, newItems, s.Slice())
assert.Equal(t, newItems, slices.Collect(s.Seq()))
// Verify it's a copy
newItems[0] = 999
@@ -224,7 +225,7 @@ func TestSlice(t *testing.T) {
original := []int{1, 2, 3}
s := NewSliceFrom(original)
copy := s.Slice()
copy := slices.Collect(s.Seq())
assert.Equal(t, original, copy)
// Verify it's a copy

View File

@@ -20,7 +20,7 @@ type Spinner struct {
type model struct {
cancel context.CancelFunc
anim anim.Anim
anim *anim.Anim
}
func (m model) Init() tea.Cmd { return m.anim.Init() }
@@ -37,7 +37,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
mm, cmd := m.anim.Update(msg)
m.anim = mm.(anim.Anim)
m.anim = mm.(*anim.Anim)
return m, cmd
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"sort"
"strings"
@@ -118,7 +119,13 @@ func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string
return
}
if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
path, err := diagParams.URI.Path()
if err != nil {
slog.Error("Failed to convert diagnostic URI to path", "uri", diagParams.URI, "error", err)
return
}
if path == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
select {
case diagChan <- struct{}{}:
default:
@@ -216,10 +223,15 @@ func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
diagnostics := client.GetDiagnostics()
if len(diagnostics) > 0 {
for location, diags := range diagnostics {
isCurrentFile := location.Path() == filePath
path, err := location.Path()
if err != nil {
slog.Error("Failed to convert diagnostic location URI to path", "uri", location, "error", err)
continue
}
isCurrentFile := path == filePath
for _, diag := range diags {
formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
formattedDiag := formatDiagnostic(path, diag, lspName)
if isCurrentFile {
fileDiagnostics = append(fileDiagnostics, formattedDiag)

View File

@@ -449,7 +449,12 @@ func (c *Client) pingTypeScriptServer(ctx context.Context) error {
// If we have any open files, try to get document symbols for one
for uri := range c.openFiles {
filePath := protocol.DocumentURI(uri).Path()
filePath, err := protocol.DocumentURI(uri).Path()
if err != nil {
slog.Error("Failed to convert URI to path for TypeScript symbol collection", "uri", uri, "error", err)
continue
}
if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
var symbols []protocol.DocumentSymbol
@@ -712,7 +717,11 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
// First collect all URIs that need to be closed
for uri := range c.openFiles {
// Convert URI back to file path using proper URI handling
filePath := protocol.DocumentURI(uri).Path()
filePath, err := protocol.DocumentURI(uri).Path()
if err != nil {
slog.Error("Failed to convert URI to path for file closing", "uri", uri, "error", err)
continue
}
filesToClose = append(filesToClose, filePath)
}
c.openFilesMu.Unlock()

View File

@@ -2,6 +2,7 @@ package protocol
import (
"fmt"
"log/slog"
)
// PatternInfo is an interface for types that represent glob patterns
@@ -36,21 +37,36 @@ func (g *GlobPattern) AsPattern() (PatternInfo, error) {
return nil, fmt.Errorf("nil pattern")
}
var err error
switch v := g.Value.(type) {
case string:
return StringPattern{Pattern: v}, nil
case RelativePattern:
// Handle BaseURI which could be string or DocumentUri
basePath := ""
switch baseURI := v.BaseURI.Value.(type) {
case string:
basePath = DocumentURI(baseURI).Path()
basePath, err = DocumentURI(baseURI).Path()
if err != nil {
slog.Error("Failed to convert URI to path", "uri", baseURI, "error", err)
return nil, fmt.Errorf("invalid URI: %s", baseURI)
}
case DocumentURI:
basePath = baseURI.Path()
basePath, err = baseURI.Path()
if err != nil {
slog.Error("Failed to convert DocumentURI to path", "uri", baseURI, "error", err)
return nil, fmt.Errorf("invalid DocumentURI: %s", baseURI)
}
default:
return nil, fmt.Errorf("unknown BaseURI type: %T", v.BaseURI.Value)
}
return RelativePatternInfo{RP: v, BasePath: basePath}, nil
default:
return nil, fmt.Errorf("unknown pattern type: %T", g.Value)
}

View File

@@ -70,7 +70,7 @@ func (uri *DocumentURI) UnmarshalText(data []byte) (err error) {
// DocumentUri("").Path() returns the empty string.
//
// Path panics if called on a URI that is not a valid filename.
func (uri DocumentURI) Path() string {
func (uri DocumentURI) Path() (string, error) {
filename, err := filename(uri)
if err != nil {
// e.g. ParseRequestURI failed.
@@ -79,22 +79,33 @@ func (uri DocumentURI) Path() string {
// direct string manipulation; all DocumentUris
// received from the client pass through
// ParseRequestURI, which ensures validity.
panic(err)
return "", fmt.Errorf("invalid URI %q: %w", uri, err)
}
return filepath.FromSlash(filename)
return filepath.FromSlash(filename), nil
}
// Dir returns the URI for the directory containing the receiver.
func (uri DocumentURI) Dir() DocumentURI {
func (uri DocumentURI) Dir() (DocumentURI, error) {
// XXX: Legacy comment:
// This function could be more efficiently implemented by avoiding any call
// to Path(), but at least consolidates URI manipulation.
return URIFromPath(uri.DirPath())
path, err := uri.DirPath()
if err != nil {
return "", fmt.Errorf("invalid URI %q: %w", uri, err)
}
return URIFromPath(path), nil
}
// DirPath returns the file path to the directory containing this URI, which
// must be a file URI.
func (uri DocumentURI) DirPath() string {
return filepath.Dir(uri.Path())
func (uri DocumentURI) DirPath() (string, error) {
path, err := uri.Path()
if err != nil {
return "", err
}
return filepath.Dir(path), nil
}
func filename(uri DocumentURI) (string, error) {

View File

@@ -11,7 +11,10 @@ import (
)
func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit) error {
path := uri.Path()
path, err := uri.Path()
if err != nil {
return fmt.Errorf("invalid URI: %w", err)
}
// Read the file content
content, err := os.ReadFile(path)
@@ -148,7 +151,11 @@ func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) {
// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
func applyDocumentChange(change protocol.DocumentChange) error {
if change.CreateFile != nil {
path := change.CreateFile.URI.Path()
path, err := change.CreateFile.URI.Path()
if err != nil {
return fmt.Errorf("invalid URI: %w", err)
}
if change.CreateFile.Options != nil {
if change.CreateFile.Options.Overwrite {
// Proceed with overwrite
@@ -164,7 +171,11 @@ func applyDocumentChange(change protocol.DocumentChange) error {
}
if change.DeleteFile != nil {
path := change.DeleteFile.URI.Path()
path, err := change.DeleteFile.URI.Path()
if err != nil {
return fmt.Errorf("invalid URI: %w", err)
}
if change.DeleteFile.Options != nil && change.DeleteFile.Options.Recursive {
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("failed to delete directory recursively: %w", err)
@@ -177,8 +188,19 @@ func applyDocumentChange(change protocol.DocumentChange) error {
}
if change.RenameFile != nil {
oldPath := change.RenameFile.OldURI.Path()
newPath := change.RenameFile.NewURI.Path()
var newPath, oldPath string
var err error
oldPath, err = change.RenameFile.OldURI.Path()
if err != nil {
return err
}
newPath, err = change.RenameFile.NewURI.Path()
if err != nil {
return err
}
if change.RenameFile.Options != nil {
if !change.RenameFile.Options.Overwrite {
if _, err := os.Stat(newPath); err == nil {

View File

@@ -617,7 +617,11 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
return false
}
// For relative patterns
basePath = protocol.DocumentURI(basePath).Path()
if basePath, err = protocol.DocumentURI(basePath).Path(); err != nil {
// XXX: Do we want to return here, or send the error up the stack?
slog.Error("Error converting base path to URI", "basePath", basePath, "error", err)
}
basePath = filepath.ToSlash(basePath)
// Make path relative to basePath for matching
@@ -660,7 +664,13 @@ func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri stri
// handleFileEvent sends file change notifications
func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
// If the file is open and it's a change event, use didChange notification
filePath := protocol.DocumentURI(uri).Path()
filePath, err := protocol.DocumentURI(uri).Path()
if err != nil {
// XXX: Do we want to return here, or send the error up the stack?
slog.Error("Error converting URI to path", "uri", uri, "error", err)
return
}
if changeType == protocol.FileChangeType(protocol.Deleted) {
w.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
} else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {

View File

@@ -6,7 +6,6 @@ import (
"image/color"
"math/rand/v2"
"strings"
"sync"
"sync/atomic"
"time"
@@ -15,6 +14,8 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lucasb-eyer/go-colorful"
"github.com/charmbracelet/crush/internal/csync"
)
const (
@@ -72,10 +73,7 @@ type animCache struct {
ellipsisFrames []string
}
var (
animCacheMutex sync.RWMutex
animCacheMap = make(map[string]*animCache)
)
var animCacheMap = csync.NewMap[string, *animCache]()
// settingsHash creates a hash key for the settings to use for caching
func settingsHash(opts Settings) string {
@@ -105,22 +103,23 @@ const ()
type Anim struct {
width int
cyclingCharWidth int
label []string
label *csync.Slice[string]
labelWidth int
labelColor color.Color
startTime time.Time
birthOffsets []time.Duration
initialFrames [][]string // frames for the initial characters
initialized bool
cyclingFrames [][]string // frames for the cycling characters
step int // current main frame step
ellipsisStep int // current ellipsis frame step
ellipsisFrames []string // ellipsis animation frames
initialized atomic.Bool
cyclingFrames [][]string // frames for the cycling characters
step atomic.Int64 // current main frame step
ellipsisStep atomic.Int64 // current ellipsis frame step
ellipsisFrames *csync.Slice[string] // ellipsis animation frames
id int
}
// New creates a new Anim instance with the specified width and label.
func New(opts Settings) (a Anim) {
func New(opts Settings) *Anim {
a := &Anim{}
// Validate settings.
if opts.Size < 1 {
opts.Size = defaultNumCyclingChars
@@ -142,16 +141,14 @@ func New(opts Settings) (a Anim) {
// Check cache first
cacheKey := settingsHash(opts)
animCacheMutex.RLock()
cached, exists := animCacheMap[cacheKey]
animCacheMutex.RUnlock()
cached, exists := animCacheMap.Get(cacheKey)
if exists {
// Use cached values
a.width = cached.width
a.labelWidth = cached.labelWidth
a.label = cached.label
a.ellipsisFrames = cached.ellipsisFrames
a.label = csync.NewSliceFrom(cached.label)
a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
a.initialFrames = cached.initialFrames
a.cyclingFrames = cached.cyclingFrames
} else {
@@ -228,17 +225,23 @@ func New(opts Settings) (a Anim) {
}
// Cache the results
labelSlice := make([]string, a.label.Len())
for i, v := range a.label.Seq2() {
labelSlice[i] = v
}
ellipsisSlice := make([]string, a.ellipsisFrames.Len())
for i, v := range a.ellipsisFrames.Seq2() {
ellipsisSlice[i] = v
}
cached = &animCache{
initialFrames: a.initialFrames,
cyclingFrames: a.cyclingFrames,
width: a.width,
labelWidth: a.labelWidth,
label: a.label,
ellipsisFrames: a.ellipsisFrames,
label: labelSlice,
ellipsisFrames: ellipsisSlice,
}
animCacheMutex.Lock()
animCacheMap[cacheKey] = cached
animCacheMutex.Unlock()
animCacheMap.Set(cacheKey, cached)
}
// Random assign a birth to each character for a stagged entrance effect.
@@ -269,28 +272,30 @@ func (a *Anim) renderLabel(label string) {
if a.labelWidth > 0 {
// Pre-render the label.
labelRunes := []rune(label)
a.label = make([]string, len(labelRunes))
for i := range a.label {
a.label[i] = lipgloss.NewStyle().
a.label = csync.NewSlice[string]()
for i := range labelRunes {
rendered := lipgloss.NewStyle().
Foreground(a.labelColor).
Render(string(labelRunes[i]))
a.label.Append(rendered)
}
// Pre-render the ellipsis frames which come after the label.
a.ellipsisFrames = make([]string, len(ellipsisFrames))
for i, frame := range ellipsisFrames {
a.ellipsisFrames[i] = lipgloss.NewStyle().
a.ellipsisFrames = csync.NewSlice[string]()
for _, frame := range ellipsisFrames {
rendered := lipgloss.NewStyle().
Foreground(a.labelColor).
Render(frame)
a.ellipsisFrames.Append(rendered)
}
} else {
a.label = nil
a.ellipsisFrames = nil
a.label = csync.NewSlice[string]()
a.ellipsisFrames = csync.NewSlice[string]()
}
}
// Width returns the total width of the animation.
func (a Anim) Width() (w int) {
func (a *Anim) Width() (w int) {
w = a.width
if a.labelWidth > 0 {
w += labelGapWidth + a.labelWidth
@@ -308,12 +313,12 @@ func (a Anim) Width() (w int) {
}
// Init starts the animation.
func (a Anim) Init() tea.Cmd {
func (a *Anim) Init() tea.Cmd {
return a.Step()
}
// Update processes animation steps (or not).
func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case StepMsg:
if msg.id != a.id {
@@ -321,19 +326,19 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
a.step++
if a.step >= len(a.cyclingFrames) {
a.step = 0
step := a.step.Add(1)
if int(step) >= len(a.cyclingFrames) {
a.step.Store(0)
}
if a.initialized && a.labelWidth > 0 {
if a.initialized.Load() && a.labelWidth > 0 {
// Manage the ellipsis animation.
a.ellipsisStep++
if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
a.ellipsisStep = 0
ellipsisStep := a.ellipsisStep.Add(1)
if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
a.ellipsisStep.Store(0)
}
} else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
a.initialized = true
} else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
a.initialized.Store(true)
}
return a, a.Step()
default:
@@ -342,35 +347,41 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// View renders the current state of the animation.
func (a Anim) View() string {
func (a *Anim) View() string {
var b strings.Builder
step := int(a.step.Load())
for i := range a.width {
switch {
case !a.initialized && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
// Birth offset not reached: render initial character.
b.WriteString(a.initialFrames[a.step][i])
b.WriteString(a.initialFrames[step][i])
case i < a.cyclingCharWidth:
// Render a cycling character.
b.WriteString(a.cyclingFrames[a.step][i])
b.WriteString(a.cyclingFrames[step][i])
case i == a.cyclingCharWidth:
// Render label gap.
b.WriteString(labelGap)
case i > a.cyclingCharWidth:
// Label.
b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
b.WriteString(labelChar)
}
}
}
// Render animated ellipsis at the end of the label if all characters
// have been initialized.
if a.initialized && a.labelWidth > 0 {
b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
if a.initialized.Load() && a.labelWidth > 0 {
ellipsisStep := int(a.ellipsisStep.Load())
if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
b.WriteString(ellipsisFrame)
}
}
return b.String()
}
// Step is a command that triggers the next step in the animation.
func (a Anim) Step() tea.Cmd {
func (a *Anim) Step() tea.Cmd {
return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
return StepMsg{id: a.id}
})

View File

@@ -56,7 +56,7 @@ func (m model) View() tea.View {
return v
}
if a, ok := m.anim.(anim.Anim); ok {
if a, ok := m.anim.(*anim.Anim); ok {
l := lipgloss.NewLayer(a.View()).
Width(a.Width()).
X(m.w/2 - a.Width()/2).

View File

@@ -49,7 +49,7 @@ type messageCmp struct {
// Core message data and state
message message.Message // The underlying message content
spinning bool // Whether to show loading animation
anim anim.Anim // Animation component for loading states
anim *anim.Anim // Animation component for loading states
// Thinking viewport for displaying reasoning content
thinkingViewport viewport.Model
@@ -95,7 +95,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinning = m.shouldSpin()
if m.spinning {
u, cmd := m.anim.Update(msg)
m.anim = u.(anim.Anim)
m.anim = u.(*anim.Anim)
return m, cmd
}
case tea.KeyPressMsg:

View File

@@ -778,7 +778,7 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
// shouldSpin determines whether the tool call should show a loading animation.
// Returns true if the tool call is not finished or if the result doesn't match the call ID.
func (m *toolCallCmp) shouldSpin() bool {
return !m.call.Finished
return !m.call.Finished && !m.cancelled
}
// Spinning returns whether the tool call is currently showing a loading animation

View File

@@ -614,6 +614,7 @@ func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
}
func (m *sidebarCmp) filesBlock() string {
return ""
t := styles.CurrentTheme()
section := t.S().Subtle.Render(

View File

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

View File

@@ -2,6 +2,7 @@ package list
import (
"regexp"
"slices"
"sort"
"strings"
@@ -179,7 +180,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.Slice() {
for _, item := range slices.Collect(f.items.Seq()) {
if i, ok := any(item).(layout.Focusable); ok {
cmds = append(cmds, i.Blur())
}

View File

@@ -1,6 +1,8 @@
package list
import (
"slices"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -89,7 +91,7 @@ func (g *groupedList[T]) convertItems() {
func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
g.groups = groups
g.convertItems()
return g.SetItems(g.items.Slice())
return g.SetItems(slices.Collect(g.items.Seq()))
}
func (g *groupedList[T]) Groups() []Group[T] {

View File

@@ -1,6 +1,7 @@
package list
import (
"slices"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
@@ -201,7 +202,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return l, nil
case anim.StepMsg:
var cmds []tea.Cmd
for _, item := range l.items.Slice() {
for _, item := range slices.Collect(l.items.Seq()) {
if i, ok := any(item).(HasAnim); ok && i.Spinning() {
updated, cmd := i.Update(msg)
cmds = append(cmds, cmd)
@@ -300,7 +301,7 @@ func (l *list[T]) viewPosition() (int, int) {
func (l *list[T]) recalculateItemPositions() {
currentContentHeight := 0
for _, item := range l.items.Slice() {
for _, item := range slices.Collect(l.items.Seq()) {
rItem, ok := l.renderedItems.Get(item.ID())
if !ok {
continue
@@ -573,7 +574,7 @@ func (l *list[T]) focusSelectedItem() tea.Cmd {
return nil
}
var cmds []tea.Cmd
for _, item := range l.items.Slice() {
for _, item := range slices.Collect(l.items.Seq()) {
if f, ok := any(item).(layout.Focusable); ok {
if item.ID() == l.selectedItem && !f.IsFocused() {
cmds = append(cmds, f.Focus())
@@ -592,7 +593,7 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
return nil
}
var cmds []tea.Cmd
for _, item := range l.items.Slice() {
for _, item := range slices.Collect(l.items.Seq()) {
if f, ok := any(item).(layout.Focusable); ok {
if item.ID() == l.selectedItem && f.IsFocused() {
cmds = append(cmds, f.Blur())
@@ -667,7 +668,7 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
l.items.Append(item)
l.indexMap = csync.NewMap[string, int]()
for inx, item := range l.items.Slice() {
for inx, item := range slices.Collect(l.items.Seq()) {
l.indexMap.Set(item.ID(), inx)
}
if l.width > 0 && l.height > 0 {
@@ -714,7 +715,7 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
}
l.items.Delete(inx)
l.renderedItems.Del(id)
for inx, item := range l.items.Slice() {
for inx, item := range slices.Collect(l.items.Seq()) {
l.indexMap.Set(item.ID(), inx)
}
@@ -779,7 +780,7 @@ func (l *list[T]) IsFocused() bool {
// Items implements List.
func (l *list[T]) Items() []T {
return l.items.Slice()
return slices.Collect(l.items.Seq())
}
func (l *list[T]) incrementOffset(n int) {
@@ -834,7 +835,7 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
}
l.items.Prepend(item)
l.indexMap = csync.NewMap[string, int]()
for inx, item := range l.items.Slice() {
for inx, item := range slices.Collect(l.items.Seq()) {
l.indexMap.Set(item.ID(), inx)
}
if l.width > 0 && l.height > 0 {
@@ -938,7 +939,7 @@ func (l *list[T]) SelectedItem() *T {
func (l *list[T]) SetItems(items []T) tea.Cmd {
l.items.SetSlice(items)
var cmds []tea.Cmd
for inx, item := range l.items.Slice() {
for inx, item := range slices.Collect(l.items.Seq()) {
if i, ok := any(item).(Indexable); ok {
i.SetIndex(inx)
}
@@ -961,7 +962,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.Slice() {
for inx, item := range slices.Collect(l.items.Seq()) {
l.indexMap.Set(item.ID(), inx)
if l.width > 0 && l.height > 0 {
cmds = append(cmds, item.SetSize(l.width, l.height))

View File

@@ -860,7 +860,7 @@ func (p *chatPage) Help() help.KeyMap {
),
key.NewBinding(
key.WithKeys("g", "home"),
key.WithHelp("g", "hone"),
key.WithHelp("g", "home"),
),
key.NewBinding(
key.WithKeys("G", "end"),