@@ -5,6 +5,7 @@ import (
9 "charm.land/bubbles/v2/key"
10 "charm.land/bubbles/v2/key"
10 tea "charm.land/bubbletea/v2"
11 tea "charm.land/bubbletea/v2"
@@ -14,6 +15,7 @@ import (
14 "github.com/charmbracelet/crush/internal/ui/list"
15 "github.com/charmbracelet/crush/internal/ui/list"
15 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/exp/ordered"
17 "github.com/charmbracelet/x/exp/ordered"
18+ "github.com/sahilm/fuzzy"
@@ -21,6 +23,20 @@ const (
21 maxHeight = 10
23 maxHeight = 10
22 minWidth = 10
24 minWidth = 10
23 maxWidth = 100
25 maxWidth = 100
27+ fullMatchWeight = 1_000
28+ baseMatchWeight = 300
30+ pathPrefixBonus = 5_000
31+ pathContainsBonus = 2_000
32+ pathContainsHintBonus = 2_500
33+ basePrefixBonus = 1_500
34+ baseContainsBonus = 500
35+ basePrefixHintBonus = 300
36+ baseContainsHintBonus = 120
38+ depthPenaltyDefault = 20
39+ depthPenaltyPathHint = 5
26// SelectionMsg is sent when a completion is selected.
42// SelectionMsg is sent when a completion is selected.
@@ -58,6 +74,11 @@ type Completions struct {
58 normalStyle lipgloss.Style
74 normalStyle lipgloss.Style
59 focusedStyle lipgloss.Style
75 focusedStyle lipgloss.Style
60 matchStyle lipgloss.Style
76 matchStyle lipgloss.Style
78+ items []*CompletionItem
79+ filtered []*CompletionItem
63// New creates a new completions component.
84// New creates a new completions component.
@@ -87,7 +108,7 @@ func (c *Completions) Query() string {
88// Size returns the visible size of the popup.
109// Size returns the visible size of the popup.
89func (c *Completions) Size() (width, height int) {
110func (c *Completions) Size() (width, height int) {
90− visible := len(c.list.FilteredItems())
111+ visible := len(c.filtered)
91 return c.width, min(visible, c.height)
112 return c.width, min(visible, c.height)
@@ -114,7 +135,7 @@ func (c *Completions) Open(depth, limit int) tea.Cmd {
115// SetItems sets the files and MCP resources and rebuilds the merged list.
136// SetItems sets the files and MCP resources and rebuilds the merged list.
116func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
137func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
117− items := make([]list.FilterableItem, 0, len(files)+len(resources))
138+ items := make([]*CompletionItem, 0, len(files)+len(resources))
119 // Add files first.
140 // Add files first.
120 for _, file := range files {
141 for _, file := range files {
@@ -142,8 +163,18 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
143 c.open = true
164 c.open = true
144 c.query = ""
165 c.query = ""
145− c.list.SetItems(items...)
166+ c.items = items
146− c.list.SetFilter("")
167+ c.paths = make([]string, len(items))
168+ c.bases = make([]string, len(items))
169+ for i, item := range items {
170+ path := item.Filter()
172+ c.bases[i] = pathBase(path)
174+ c.filtered = c.rank(queryContext{
177+ c.setVisibleItems(c.filtered)
147 c.list.Focus()
178 c.list.Focus()
149 c.width = maxWidth
180 c.width = maxWidth
@@ -171,13 +202,16 @@ func (c *Completions) Filter(query string) {
173 c.query = query
204 c.query = query
174− c.list.SetFilter(query)
205+ c.filtered = c.rank(queryContext{
208+ c.setVisibleItems(c.filtered)
176 c.updateSize()
210 c.updateSize()
179func (c *Completions) updateSize() {
213func (c *Completions) updateSize() {
180− items := c.list.FilteredItems()
214+ items := c.filtered
181 start, end := c.list.VisibleItemIndices()
215 start, end := c.list.VisibleItemIndices()
182 width := 0
216 width := 0
183 for i := start; i <= end; i++ {
217 for i := start; i <= end; i++ {
@@ -197,7 +231,7 @@ func (c *Completions) updateSize() {
198// HasItems returns whether there are visible items.
232// HasItems returns whether there are visible items.
199func (c *Completions) HasItems() bool {
233func (c *Completions) HasItems() bool {
200− return len(c.list.FilteredItems()) > 0
234+ return len(c.filtered) > 0
203// Update handles key events for the completions.
237// Update handles key events for the completions.
@@ -236,7 +270,7 @@ func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
237// selectPrev selects the previous item with circular navigation.
271// selectPrev selects the previous item with circular navigation.
238func (c *Completions) selectPrev() {
272func (c *Completions) selectPrev() {
239− items := c.list.FilteredItems()
273+ items := c.filtered
240 if len(items) == 0 {
274 if len(items) == 0 {
@@ -248,7 +282,7 @@ func (c *Completions) selectPrev() {
249// selectNext selects the next item with circular navigation.
283// selectNext selects the next item with circular navigation.
250func (c *Completions) selectNext() {
284func (c *Completions) selectNext() {
251− items := c.list.FilteredItems()
285+ items := c.filtered
252 if len(items) == 0 {
286 if len(items) == 0 {
@@ -260,7 +294,7 @@ func (c *Completions) selectNext() {
261// selectCurrent returns a command with the currently selected item.
295// selectCurrent returns a command with the currently selected item.
262func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
296func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
263− items := c.list.FilteredItems()
297+ items := c.filtered
264 if len(items) == 0 {
298 if len(items) == 0 {
265 return nil
299 return nil
@@ -270,10 +304,7 @@ func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
270 return nil
304 return nil
273− item, ok := items[selected].(*CompletionItem)
307+ item := items[selected]
278 if !keepOpen {
309 if !keepOpen {
279 c.open = false
310 c.open = false
@@ -301,7 +332,7 @@ func (c *Completions) Render() string {
301 return ""
332 return ""
304− items := c.list.FilteredItems()
335+ items := c.filtered
305 if len(items) == 0 {
336 if len(items) == 0 {
306 return ""
337 return ""
@@ -309,6 +340,200 @@ func (c *Completions) Render() string {
309 return c.list.Render()
340 return c.list.Render()
343+func (c *Completions) setVisibleItems(items []*CompletionItem) {
344+ filterables := make([]list.FilterableItem, 0, len(items))
345+ for _, item := range items {
346+ filterables = append(filterables, item)
348+ c.list.SetItems(filterables...)
351+type queryContext struct {
355+type rankedItem struct {
356+ item *CompletionItem
360+// rank uses path-first fuzzy ordering with basename as a secondary boost.
361+func (c *Completions) rank(ctx queryContext) []*CompletionItem {
362+ query := strings.TrimSpace(ctx.query)
364+ for _, item := range c.items {
365+ item.SetMatch(fuzzy.Match{})
370+ fullMatches := matchIndex(query, c.paths)
371+ baseMatches := matchIndex(query, c.bases)
372+ allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
373+ for idx := range fullMatches {
374+ allIndexes[idx] = struct{}{}
376+ for idx := range baseMatches {
377+ allIndexes[idx] = struct{}{}
380+ queryLower := strings.ToLower(query)
381+ pathHint := hasPathHint(query)
382+ ranked := make([]rankedItem, 0, len(allIndexes))
383+ for idx := range allIndexes {
384+ path := c.paths[idx]
385+ pathLower := strings.ToLower(path)
386+ baseLower := strings.ToLower(c.bases[idx])
388+ fullMatch, hasFullMatch := fullMatches[idx]
389+ baseMatch, hasBaseMatch := baseMatches[idx]
391+ pathPrefix := strings.HasPrefix(pathLower, queryLower)
392+ pathContains := strings.Contains(pathLower, queryLower)
393+ basePrefix := strings.HasPrefix(baseLower, queryLower)
394+ baseContains := strings.Contains(baseLower, queryLower)
398+ score += fullMatch.Score * fullMatchWeight
401+ score += baseMatch.Score * baseMatchWeight
404+ score += pathPrefixBonus
407+ score += pathContainsBonus
411+ score += pathContainsHintBonus
414+ score += basePrefixHintBonus
417+ score += baseContainsHintBonus
421+ score += basePrefixBonus
424+ score += baseContainsBonus
428+ depthPenalty := depthPenaltyDefault
430+ depthPenalty = depthPenaltyPathHint
432+ score -= strings.Count(path, "/") * depthPenalty
433+ score -= ansi.StringWidth(path)
435+ if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
436+ c.items[idx].SetMatch(fullMatch)
437+ } else if hasBaseMatch {
438+ c.items[idx].SetMatch(remapMatchToPath(baseMatch, path))
440+ c.items[idx].SetMatch(fuzzy.Match{})
443+ ranked = append(ranked, rankedItem{
449+ slices.SortStableFunc(ranked, func(a, b rankedItem) int {
450+ if a.score != b.score {
451+ return b.score - a.score
453+ return strings.Compare(a.item.Text(), b.item.Text())
456+ result := make([]*CompletionItem, 0, len(ranked))
457+ for _, item := range ranked {
458+ result = append(result, item.item)
463+func matchIndex(query string, values []string) map[int]fuzzy.Match {
464+ source := stringSource(values)
465+ matches := fuzzy.FindFrom(query, source)
466+ result := make(map[int]fuzzy.Match, len(matches))
467+ for _, match := range matches {
468+ result[match.Index] = match
473+type stringSource []string
475+func (s stringSource) Len() int {
479+func (s stringSource) String(i int) string {
483+func pathBase(value string) string {
484+ trimmed := strings.TrimRight(value, `/\`)
488+ idx := strings.LastIndexAny(trimmed, `/\`)
492+ return trimmed[idx+1:]
495+func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
496+ base := pathBase(fullPath)
500+ offset := len(fullPath) - len(base)
501+ remapped := make([]int, 0, len(match.MatchedIndexes))
502+ for _, idx := range match.MatchedIndexes {
503+ remapped = append(remapped, offset+idx)
505+ match.MatchedIndexes = remapped
509+func hasPathHint(query string) bool {
510+ if strings.Contains(query, "/") || strings.Contains(query, "\\") {
514+ lastDot := strings.LastIndex(query, ".")
515+ if lastDot < 0 || lastDot == len(query)-1 {
519+ suffix := query[lastDot+1:]
520+ if len(suffix) > 12 {
525+ for _, r := range suffix {
526+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
529+ if unicode.IsLetter(r) {
312func loadFiles(depth, limit int) []FileCompletionValue {
537func loadFiles(depth, limit int) []FileCompletionValue {
313 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
538 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
314 slices.Sort(files)
539 slices.Sort(files)