fix: allow multi word search

kujtimiihoxha created

Change summary

internal/tui/exp/list/filterable_group.go | 159 ++++++++++++++++++------
1 file changed, 116 insertions(+), 43 deletions(-)

Detailed changes

internal/tui/exp/list/filterable_group.go 🔗

@@ -180,7 +180,12 @@ func (f *filterableGroupList[T]) inputHeight() int {
 	return lipgloss.Height(f.inputStyle.Render(f.input.View()))
 }
 
-func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
+type groupMatch[T FilterableItem] struct {
+	group Group[T]
+	score int
+}
+
+func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
 	var cmds []tea.Cmd
 	for _, item := range slices.Collect(f.items.Seq()) {
 		if i, ok := any(item).(layout.Focusable); ok {
@@ -190,67 +195,135 @@ func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
 			i.MatchIndexes(make([]int, 0))
 		}
 	}
+	return cmds
+}
 
-	f.selectedItem = ""
+func (f *filterableGroupList[T]) getGroupName(g Group[T]) string {
+	if section, ok := g.Section.(*itemSectionModel); ok {
+		return strings.ToLower(section.title)
+	}
+	return strings.ToLower(g.Section.ID())
+}
+
+func (f *filterableGroupList[T]) setMatchIndexes(item T, indexes []int) {
+	if i, ok := any(item).(HasMatchIndexes); ok {
+		i.MatchIndexes(indexes)
+	}
+}
+
+func (f *filterableGroupList[T]) findMatchingGroups(firstWord string) []groupMatch[T] {
+	var matchedGroups []groupMatch[T]
+	for _, g := range f.groups {
+		groupName := f.getGroupName(g)
+		matches := fuzzy.Find(firstWord, []string{groupName})
+		if len(matches) > 0 && matches[0].Score > 0 {
+			matchedGroups = append(matchedGroups, groupMatch[T]{
+				group: g,
+				score: matches[0].Score,
+			})
+		}
+	}
+	// Sort by score (higher scores first - exact matches will have higher scores)
+	sort.SliceStable(matchedGroups, func(i, j int) bool {
+		return matchedGroups[i].score > matchedGroups[j].score
+	})
+	return matchedGroups
+}
+
+func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string) []T {
 	if query == "" {
-		return f.groupedList.SetGroups(f.groups)
+		// No query, return all items with cleared match indexes
+		var items []T
+		for _, item := range group.Items {
+			f.setMatchIndexes(item, make([]int, 0))
+			items = append(items, item)
+		}
+		return items
 	}
 
+	// Build search words
+	words := make([]string, len(group.Items))
+	for i, item := range group.Items {
+		words[i] = strings.ToLower(item.FilterValue())
+	}
+
+	// Perform fuzzy search
+	matches := fuzzy.Find(query, words)
+	sort.SliceStable(matches, func(i, j int) bool {
+		return matches[i].Score > matches[j].Score
+	})
+
+	if len(matches) > 0 {
+		// Found matches, return only those with highlights
+		var matchedItems []T
+		for _, match := range matches {
+			item := group.Items[match.Index]
+			f.setMatchIndexes(item, match.MatchedIndexes)
+			matchedItems = append(matchedItems, item)
+		}
+		return matchedItems
+	}
+
+	// No matches, return all items without highlights
+	var allItems []T
+	for _, item := range group.Items {
+		f.setMatchIndexes(item, make([]int, 0))
+		allItems = append(allItems, item)
+	}
+	return allItems
+}
+
+func (f *filterableGroupList[T]) searchAllGroups(query string) []Group[T] {
 	var newGroups []Group[T]
 	for _, g := range f.groups {
-		// Check if group name matches the query
-		// Extract the group name from the section - we'll use the section's view content
-		// as a fallback since ItemSection doesn't implement FilterableItem
-		var groupName string
-		if section, ok := g.Section.(*itemSectionModel); ok {
-			groupName = strings.ToLower(section.title)
-		} else {
-			// Fallback to using the section's ID or view content
-			groupName = strings.ToLower(g.Section.ID())
-		}
-		groupMatches := fuzzy.Find(query, []string{groupName})
-
-		if len(groupMatches) > 0 && groupMatches[0].Score > 0 {
-			// If group name matches, include all items from this group
-			// Clear any existing match indexes for items since the group matched
-			for _, item := range g.Items {
-				if i, ok := any(item).(HasMatchIndexes); ok {
-					i.MatchIndexes(make([]int, 0))
-				}
-			}
+		matchedItems := f.filterItemsInGroup(g, query)
+		if len(matchedItems) > 0 {
 			newGroups = append(newGroups, Group[T]{
 				Section: g.Section,
-				Items:   g.Items,
+				Items:   matchedItems,
 			})
-		} else {
-			// Group name doesn't match, check individual items
-			words := make([]string, len(g.Items))
-			for i, item := range g.Items {
-				words[i] = strings.ToLower(item.FilterValue())
-			}
+		}
+	}
+	return newGroups
+}
 
-			matches := fuzzy.Find(query, words)
+func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
+	cmds := f.clearItemState()
+	f.selectedItem = ""
 
-			sort.SliceStable(matches, func(i, j int) bool {
-				return matches[i].Score > matches[j].Score
-			})
+	if query == "" {
+		return f.groupedList.SetGroups(f.groups)
+	}
 
-			var matchedItems []T
-			for _, match := range matches {
-				item := g.Items[match.Index]
-				if i, ok := any(item).(HasMatchIndexes); ok {
-					i.MatchIndexes(match.MatchedIndexes)
-				}
-				matchedItems = append(matchedItems, item)
-			}
+	lowerQuery := strings.ToLower(query)
+	queryWords := strings.Fields(lowerQuery)
+	firstWord := queryWords[0]
+
+	// Find groups that match the first word
+	matchedGroups := f.findMatchingGroups(firstWord)
+
+	var newGroups []Group[T]
+	if len(matchedGroups) > 0 {
+		// Filter within matched groups using remaining words
+		remainingQuery := ""
+		if len(queryWords) > 1 {
+			remainingQuery = strings.Join(queryWords[1:], " ")
+		}
+
+		for _, matchedGroup := range matchedGroups {
+			matchedItems := f.filterItemsInGroup(matchedGroup.group, remainingQuery)
 			if len(matchedItems) > 0 {
 				newGroups = append(newGroups, Group[T]{
-					Section: g.Section,
+					Section: matchedGroup.group.Section,
 					Items:   matchedItems,
 				})
 			}
 		}
+	} else {
+		// No group matches, search all groups
+		newGroups = f.searchAllGroups(lowerQuery)
 	}
+
 	cmds = append(cmds, f.groupedList.SetGroups(newGroups))
 	return tea.Batch(cmds...)
 }