diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go index 0e960fc9088be19f821e345a28cd03802573611b..10298b92041e6a1cfb3ad1ae4a5ca9f1c38b98d5 100644 --- a/internal/tui/exp/list/filterable_group.go +++ b/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...) }