mirror of
				https://github.com/charmbracelet/crush.git
				synced 2025-08-02 05:20:46 +03:00 
			
		
		
		
	Merge branch 'list' into tool_improvements
This commit is contained in:
		| @@ -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 }}" | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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) | ||||
| 	} | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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} | ||||
| 	}) | ||||
|   | ||||
| @@ -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). | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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()) | ||||
| 		} | ||||
|   | ||||
| @@ -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] { | ||||
|   | ||||
| @@ -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)) | ||||
|   | ||||
| @@ -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"), | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Kujtim Hoxha
					Kujtim Hoxha