Skip to content
This repository was archived by the owner on Sep 18, 2025. It is now read-only.

Commit 2329178

Browse files
committed
wip list sections
1 parent b787dc0 commit 2329178

9 files changed

Lines changed: 370 additions & 764 deletions

File tree

internal/tui/components/core/list/list.go

Lines changed: 214 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414
"github.com/opencode-ai/opencode/internal/tui/components/anim"
1515
"github.com/opencode-ai/opencode/internal/tui/layout"
1616
"github.com/opencode-ai/opencode/internal/tui/styles"
17-
"github.com/opencode-ai/opencode/internal/tui/theme"
1817
"github.com/opencode-ai/opencode/internal/tui/util"
1918
"github.com/sahilm/fuzzy"
2019
)
@@ -60,6 +59,13 @@ type HasMatchIndexes interface {
6059
MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
6160
}
6261

62+
// SectionHeader interface identifies items that are section headers.
63+
// Section headers are rendered differently and are skipped during navigation.
64+
type SectionHeader interface {
65+
util.Model
66+
IsSectionHeader() bool // Returns true if this item is a section header
67+
}
68+
6369
// renderedItem represents a cached rendered item with its position and content.
6470
type renderedItem struct {
6571
lines []string // The rendered lines of text for this item
@@ -539,32 +545,87 @@ func (r *reverseRenderer) renderItemLines(item util.Model) []string {
539545

540546
// selectPreviousItem moves selection to the previous item in the list.
541547
// Handles focus management and ensures the selected item remains visible.
548+
// Skips section headers during navigation.
542549
func (m *model) selectPreviousItem() tea.Cmd {
543550
if m.selectionState.selectedIndex <= 0 {
544551
return nil
545552
}
546553

547554
cmds := []tea.Cmd{m.blurSelected()}
548555
m.selectionState.selectedIndex--
556+
557+
// Skip section headers
558+
for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) {
559+
m.selectionState.selectedIndex--
560+
}
561+
562+
// If we went past the beginning, stay at the first non-header item
563+
if m.selectionState.selectedIndex < 0 {
564+
m.selectionState.selectedIndex = m.findFirstSelectableItem()
565+
}
566+
549567
cmds = append(cmds, m.focusSelected())
550568
m.ensureSelectedItemVisible()
551569
return tea.Batch(cmds...)
552570
}
553571

554572
// selectNextItem moves selection to the next item in the list.
555573
// Handles focus management and ensures the selected item remains visible.
574+
// Skips section headers during navigation.
556575
func (m *model) selectNextItem() tea.Cmd {
557576
if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
558577
return nil
559578
}
560579

561580
cmds := []tea.Cmd{m.blurSelected()}
562581
m.selectionState.selectedIndex++
582+
583+
// Skip section headers
584+
for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) {
585+
m.selectionState.selectedIndex++
586+
}
587+
588+
// If we went past the end, stay at the last non-header item
589+
if m.selectionState.selectedIndex >= len(m.filteredItems) {
590+
m.selectionState.selectedIndex = m.findLastSelectableItem()
591+
}
592+
563593
cmds = append(cmds, m.focusSelected())
564594
m.ensureSelectedItemVisible()
565595
return tea.Batch(cmds...)
566596
}
567597

598+
// isSectionHeader checks if the item at the given index is a section header.
599+
func (m *model) isSectionHeader(index int) bool {
600+
if index < 0 || index >= len(m.filteredItems) {
601+
return false
602+
}
603+
if header, ok := m.filteredItems[index].(SectionHeader); ok {
604+
return header.IsSectionHeader()
605+
}
606+
return false
607+
}
608+
609+
// findFirstSelectableItem finds the first item that is not a section header.
610+
func (m *model) findFirstSelectableItem() int {
611+
for i := 0; i < len(m.filteredItems); i++ {
612+
if !m.isSectionHeader(i) {
613+
return i
614+
}
615+
}
616+
return NoSelection
617+
}
618+
619+
// findLastSelectableItem finds the last item that is not a section header.
620+
func (m *model) findLastSelectableItem() int {
621+
for i := len(m.filteredItems) - 1; i >= 0; i-- {
622+
if !m.isSectionHeader(i) {
623+
return i
624+
}
625+
}
626+
return NoSelection
627+
}
628+
568629
// ensureSelectedItemVisible scrolls the list to make the selected item visible.
569630
// Uses different strategies for forward and reverse rendering modes.
570631
func (m *model) ensureSelectedItemVisible() {
@@ -631,25 +692,25 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
631692
}
632693
}
633694

634-
// goToBottom switches to reverse mode and selects the last item.
695+
// goToBottom switches to reverse mode and selects the last selectable item.
635696
// Commonly used for chat-like interfaces where new content appears at the bottom.
697+
// Skips section headers when selecting the last item.
636698
func (m *model) goToBottom() tea.Cmd {
637699
cmds := []tea.Cmd{m.blurSelected()}
638700
m.viewState.reverse = true
639-
m.selectionState.selectedIndex = len(m.filteredItems) - 1
701+
m.selectionState.selectedIndex = m.findLastSelectableItem()
640702
cmds = append(cmds, m.focusSelected())
641703
m.ResetView()
642704
return tea.Batch(cmds...)
643705
}
644706

645-
// goToTop switches to forward mode and selects the first item.
707+
// goToTop switches to forward mode and selects the first selectable item.
646708
// Standard behavior for most list interfaces.
709+
// Skips section headers when selecting the first item.
647710
func (m *model) goToTop() tea.Cmd {
648711
cmds := []tea.Cmd{m.blurSelected()}
649712
m.viewState.reverse = false
650-
if len(m.filteredItems) > 0 {
651-
m.selectionState.selectedIndex = 0
652-
}
713+
m.selectionState.selectedIndex = m.findFirstSelectableItem()
653714
cmds = append(cmds, m.focusSelected())
654715
m.ResetView()
655716
return tea.Batch(cmds...)
@@ -715,8 +776,12 @@ func (m *model) rerenderItem(inx int) {
715776
}
716777

717778
// getItemLines converts an item to its rendered lines, including any gap spacing.
779+
// Handles section headers with special styling.
718780
func (m *model) getItemLines(item util.Model) []string {
719-
itemLines := strings.Split(item.View().String(), "\n")
781+
var itemLines []string
782+
783+
itemLines = strings.Split(item.View().String(), "\n")
784+
720785
if m.gapSize > 0 {
721786
gap := make([]string, m.gapSize)
722787
itemLines = append(itemLines, gap...)
@@ -995,6 +1060,7 @@ func (m *model) setReverse(reverse bool) {
9951060

9961061
// SetItems replaces all items in the list with a new set.
9971062
// Initializes all items, sets their sizes, and establishes initial selection.
1063+
// Ensures the initial selection skips section headers.
9981064
func (m *model) SetItems(items []util.Model) tea.Cmd {
9991065
m.allItems = items
10001066
m.filteredItems = items
@@ -1006,9 +1072,9 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
10061072

10071073
if len(m.filteredItems) > 0 {
10081074
if m.viewState.reverse {
1009-
m.selectionState.selectedIndex = len(m.filteredItems) - 1
1075+
m.selectionState.selectedIndex = m.findLastSelectableItem()
10101076
} else {
1011-
m.selectionState.selectedIndex = 0
1077+
m.selectionState.selectedIndex = m.findFirstSelectableItem()
10121078
}
10131079
if cmd := m.focusSelected(); cmd != nil {
10141080
cmds = append(cmds, cmd)
@@ -1022,18 +1088,75 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
10221088
}
10231089

10241090
func (c *model) inputStyle() lipgloss.Style {
1025-
t := theme.CurrentTheme()
1026-
return styles.BaseStyle().
1027-
BorderStyle(lipgloss.NormalBorder()).
1028-
BorderForeground(t.TextMuted()).
1029-
BorderBackground(t.Background()).
1030-
BorderBottom(true)
1091+
return styles.BaseStyle()
1092+
}
1093+
1094+
// section represents a group of items under a section header.
1095+
type section struct {
1096+
header SectionHeader
1097+
items []util.Model
1098+
}
1099+
1100+
// parseSections parses the flat item list into sections.
1101+
func (m *model) parseSections() []section {
1102+
var sections []section
1103+
var currentSection *section
1104+
1105+
for _, item := range m.allItems {
1106+
if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() {
1107+
// Start a new section
1108+
if currentSection != nil {
1109+
sections = append(sections, *currentSection)
1110+
}
1111+
currentSection = &section{
1112+
header: header,
1113+
items: []util.Model{},
1114+
}
1115+
} else if currentSection != nil {
1116+
// Add item to current section
1117+
currentSection.items = append(currentSection.items, item)
1118+
} else {
1119+
// Item without a section header - create an implicit section
1120+
if len(sections) == 0 || sections[len(sections)-1].header != nil {
1121+
sections = append(sections, section{
1122+
header: nil,
1123+
items: []util.Model{item},
1124+
})
1125+
} else {
1126+
// Add to the last implicit section
1127+
sections[len(sections)-1].items = append(sections[len(sections)-1].items, item)
1128+
}
1129+
}
1130+
}
1131+
1132+
// Don't forget the last section
1133+
if currentSection != nil {
1134+
sections = append(sections, *currentSection)
1135+
}
1136+
1137+
return sections
1138+
}
1139+
1140+
// flattenSections converts sections back to a flat list.
1141+
func (m *model) flattenSections(sections []section) []util.Model {
1142+
var result []util.Model
1143+
1144+
for _, sect := range sections {
1145+
if sect.header != nil {
1146+
result = append(result, sect.header)
1147+
}
1148+
result = append(result, sect.items...)
1149+
}
1150+
1151+
return result
10311152
}
10321153

10331154
func (m *model) filter(search string) tea.Cmd {
10341155
var cmds []tea.Cmd
10351156
search = strings.TrimSpace(search)
10361157
search = strings.ToLower(search)
1158+
1159+
// Clear focus and match indexes from all items
10371160
for _, item := range m.allItems {
10381161
if i, ok := item.(layout.Focusable); ok {
10391162
cmds = append(cmds, i.Blur())
@@ -1042,34 +1165,32 @@ func (m *model) filter(search string) tea.Cmd {
10421165
i.MatchIndexes(make([]int, 0))
10431166
}
10441167
}
1168+
10451169
if search == "" {
1046-
cmds = append(cmds, m.SetItems(m.allItems)) // Reset to all items if search is empty
1170+
cmds = append(cmds, m.SetItems(m.allItems))
10471171
return tea.Batch(cmds...)
10481172
}
1049-
words := make([]string, 0, len(m.allItems))
1050-
for _, cmd := range m.allItems {
1051-
if f, ok := cmd.(HasFilterValue); ok {
1052-
words = append(words, strings.ToLower(f.FilterValue()))
1053-
} else {
1054-
words = append(words, strings.ToLower(""))
1055-
}
1056-
}
1057-
matches := fuzzy.Find(search, words)
1058-
sort.Sort(matches)
1059-
filteredItems := make([]util.Model, 0, len(matches))
1060-
for _, match := range matches {
1061-
item := m.allItems[match.Index]
1062-
if i, ok := item.(HasMatchIndexes); ok {
1063-
i.MatchIndexes(match.MatchedIndexes)
1173+
1174+
// Parse items into sections
1175+
sections := m.parseSections()
1176+
var filteredSections []section
1177+
1178+
for _, sect := range sections {
1179+
filteredSection := m.filterSection(sect, search)
1180+
if filteredSection != nil {
1181+
filteredSections = append(filteredSections, *filteredSection)
10641182
}
1065-
filteredItems = append(filteredItems, item)
10661183
}
1067-
m.filteredItems = filteredItems
1068-
if len(filteredItems) > 0 {
1184+
1185+
// Rebuild flat list from filtered sections
1186+
m.filteredItems = m.flattenSections(filteredSections)
1187+
1188+
// Set initial selection
1189+
if len(m.filteredItems) > 0 {
10691190
if m.viewState.reverse {
1070-
m.selectionState.selectedIndex = len(filteredItems) - 1
1191+
m.selectionState.selectedIndex = m.findLastSelectableItem()
10711192
} else {
1072-
m.selectionState.selectedIndex = 0
1193+
m.selectionState.selectedIndex = m.findFirstSelectableItem()
10731194
}
10741195
if cmd := m.focusSelected(); cmd != nil {
10751196
cmds = append(cmds, cmd)
@@ -1081,3 +1202,59 @@ func (m *model) filter(search string) tea.Cmd {
10811202
m.ResetView()
10821203
return tea.Batch(cmds...)
10831204
}
1205+
1206+
// filterSection filters items within a section and returns the section if it has matches.
1207+
func (m *model) filterSection(sect section, search string) *section {
1208+
var matchedItems []util.Model
1209+
var hasHeaderMatch bool
1210+
1211+
// Check if section header itself matches
1212+
if sect.header != nil {
1213+
headerText := strings.ToLower(sect.header.View().String())
1214+
if strings.Contains(headerText, search) {
1215+
hasHeaderMatch = true
1216+
// If header matches, include all items in the section
1217+
matchedItems = sect.items
1218+
}
1219+
}
1220+
1221+
// If header didn't match, filter items within the section
1222+
if !hasHeaderMatch && len(sect.items) > 0 {
1223+
// Create words array for items in this section
1224+
words := make([]string, len(sect.items))
1225+
for i, item := range sect.items {
1226+
if f, ok := item.(HasFilterValue); ok {
1227+
words[i] = strings.ToLower(f.FilterValue())
1228+
} else {
1229+
words[i] = ""
1230+
}
1231+
}
1232+
1233+
// Find matches within this section
1234+
matches := fuzzy.Find(search, words)
1235+
1236+
// Sort matches by score but preserve relative order for equal scores
1237+
sort.SliceStable(matches, func(i, j int) bool {
1238+
return matches[i].Score > matches[j].Score
1239+
})
1240+
1241+
// Build matched items list
1242+
for _, match := range matches {
1243+
item := sect.items[match.Index]
1244+
if i, ok := item.(HasMatchIndexes); ok {
1245+
i.MatchIndexes(match.MatchedIndexes)
1246+
}
1247+
matchedItems = append(matchedItems, item)
1248+
}
1249+
}
1250+
1251+
// Return section only if it has matches
1252+
if len(matchedItems) > 0 {
1253+
return &section{
1254+
header: sect.header,
1255+
items: matchedItems,
1256+
}
1257+
}
1258+
1259+
return nil
1260+
}

0 commit comments

Comments
 (0)