Skip to content

Commit 8359cbb

Browse files
stefanpennerclaude
andcommitted
Add search/filtering to TUI tree
Press `/` to enter search mode, type to filter the tree live with case-insensitive substring matching. Ancestors of matches stay visible for context and are auto-expanded. Two-tone highlighting: matching rows get a subtle purple-tinted background, exact matching characters get a stronger bold purple style. `Enter` or `Esc` clears the filter while preserving cursor position on the selected item. Also fixes shorthand GitHub URL args (e.g. `owner/repo/pull/123`) being misidentified as tokens by the CLI arg parser. Closes #19 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5e8782a commit 8359cbb

File tree

8 files changed

+715
-2
lines changed

8 files changed

+715
-2
lines changed

cmd/gha-analyzer/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ func main() {
213213
if token == "" {
214214
for i, arg := range args {
215215
if !strings.HasPrefix(arg, "http") && !strings.HasPrefix(arg, "-") {
216+
// Skip arguments that look like GitHub URLs (shorthand or full)
217+
if _, err := utils.ParseGitHubURL(arg); err == nil {
218+
continue
219+
}
216220
token = arg
217221
args = append(args[:i], args[i+1:]...)
218222
break

pkg/tui/results/items.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,18 @@ func FlattenVisibleItems(items []*TreeItem, expandedState map[string]bool) []Tre
230230
return result
231231
}
232232

233+
// FilterVisibleItems filters already-flattened visible items to only include
234+
// items whose ID is in matchIDs or ancestorIDs.
235+
func FilterVisibleItems(items []TreeItem, matchIDs, ancestorIDs map[string]bool) []TreeItem {
236+
var result []TreeItem
237+
for _, item := range items {
238+
if matchIDs[item.ID] || ancestorIDs[item.ID] {
239+
result = append(result, item)
240+
}
241+
}
242+
return result
243+
}
244+
233245
func makeNodeID(parentID, name string, index int) string {
234246
if parentID == "" {
235247
return fmt.Sprintf("%s/%d", name, index)

pkg/tui/results/keys.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type KeyMap struct {
1919
ExpandAll key.Binding
2020
CollapseAll key.Binding
2121
Perfetto key.Binding
22+
Search key.Binding
2223
Mouse key.Binding
2324
Help key.Binding
2425
Quit key.Binding
@@ -87,6 +88,10 @@ func DefaultKeyMap() KeyMap {
8788
key.WithKeys("p"),
8889
key.WithHelp("p", "perfetto"),
8990
),
91+
Search: key.NewBinding(
92+
key.WithKeys("/"),
93+
key.WithHelp("/", "search"),
94+
),
9095
Mouse: key.NewBinding(
9196
key.WithKeys("m"),
9297
key.WithHelp("m", "toggle mouse"),
@@ -104,7 +109,7 @@ func DefaultKeyMap() KeyMap {
104109

105110
// ShortHelp returns a short help string for the footer
106111
func (k KeyMap) ShortHelp() string {
107-
return "↑↓ nav • ←→ expand/collapse • space toggle • o open • ? help • q quit"
112+
return "↑↓ nav • ←→ expand/collapse • space toggle • / search • o open • ? help • q quit"
108113
}
109114

110115
// FullHelp returns all key bindings for the help modal
@@ -125,6 +130,7 @@ func (k KeyMap) FullHelp() [][]string {
125130
{"c", "Collapse all"},
126131
{"r", "Reload data"},
127132
{"p", "Open in Perfetto"},
133+
{"/", "Search/filter"},
128134
{"m", "Toggle mouse mode"},
129135
{"?", "Show this help"},
130136
{"q", "Quit"},

pkg/tui/results/model.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"strings"
66
"time"
7+
"unicode/utf8"
78

89
"github.com/charmbracelet/bubbles/key"
910
"github.com/charmbracelet/bubbles/spinner"
@@ -86,6 +87,11 @@ type Model struct {
8687
openPerfettoFunc func()
8788
// Mouse mode state
8889
mouseEnabled bool
90+
// Search/filter state
91+
isSearching bool
92+
searchQuery string
93+
searchMatchIDs map[string]bool // IDs of items matching the query (not ancestors)
94+
searchAncIDs map[string]bool // IDs of ancestor items (for context)
8995
}
9096

9197
// ReloadFunc is the function signature for reloading data
@@ -341,6 +347,63 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
341347
return m, nil
342348
}
343349

350+
// Handle search input mode
351+
if m.isSearching {
352+
switch msg.Type {
353+
case tea.KeyEsc:
354+
m.isSearching = false
355+
m.searchQuery = ""
356+
m.searchMatchIDs = nil
357+
m.searchAncIDs = nil
358+
m.rebuildItems()
359+
return m, nil
360+
case tea.KeyEnter, tea.KeyDown, tea.KeyTab:
361+
// Exit search input but keep filter active
362+
m.isSearching = false
363+
return m, nil
364+
case tea.KeyBackspace:
365+
if len(m.searchQuery) > 0 {
366+
_, size := utf8.DecodeLastRuneInString(m.searchQuery)
367+
m.searchQuery = m.searchQuery[:len(m.searchQuery)-size]
368+
}
369+
m.applySearchFilter()
370+
m.rebuildItems()
371+
return m, nil
372+
default:
373+
if msg.Type == tea.KeyRunes {
374+
m.searchQuery += string(msg.Runes)
375+
m.applySearchFilter()
376+
m.rebuildItems()
377+
}
378+
return m, nil
379+
}
380+
}
381+
382+
// Esc or Enter clears active search filter (when not in input mode).
383+
// Enter preserves cursor on the current item in the full tree;
384+
// Esc simply clears and resets.
385+
if m.searchQuery != "" && (msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter) {
386+
// Remember current item ID so we can find it after rebuild
387+
var curID string
388+
if m.cursor >= 0 && m.cursor < len(m.visibleItems) {
389+
curID = m.visibleItems[m.cursor].ID
390+
}
391+
m.searchQuery = ""
392+
m.searchMatchIDs = nil
393+
m.searchAncIDs = nil
394+
m.rebuildItems()
395+
// Restore cursor to the same item in the unfiltered list
396+
if curID != "" {
397+
for i, item := range m.visibleItems {
398+
if item.ID == curID {
399+
m.cursor = i
400+
break
401+
}
402+
}
403+
}
404+
return m, nil
405+
}
406+
344407
switch {
345408
case key.Matches(msg, m.keys.Quit):
346409
return m, tea.Quit
@@ -422,6 +485,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
422485
}
423486
return m, tea.DisableMouse
424487

488+
case key.Matches(msg, m.keys.Search):
489+
m.isSearching = true
490+
m.searchQuery = ""
491+
m.searchMatchIDs = nil
492+
m.searchAncIDs = nil
493+
return m, nil
494+
425495
case key.Matches(msg, m.keys.Help):
426496
m.showHelpModal = true
427497
return m, nil
@@ -532,6 +602,10 @@ func (m Model) View() string {
532602
// Calculate available height for items
533603
headerLines := 9 // header box (6 lines) + 1 newline + time axis + 1 blank line
534604
footerLines := 3 // blank line + help line + bottom border
605+
searchActive := m.isSearching || m.searchQuery != ""
606+
if searchActive {
607+
headerLines++ // search bar takes one line
608+
}
535609
availableHeight := height - headerLines - footerLines
536610
if availableHeight < 1 {
537611
availableHeight = 10
@@ -559,6 +633,12 @@ func (m Model) View() string {
559633
b.WriteString(blankLine)
560634
b.WriteString("\n")
561635

636+
// Search bar (between blank line and content)
637+
if searchActive {
638+
b.WriteString(m.renderSearchBar(contentWidth))
639+
b.WriteString("\n")
640+
}
641+
562642
// Determine scroll window
563643
startIdx := 0
564644
endIdx := totalItems
@@ -709,11 +789,75 @@ func addHorizontalPadding(content string, pad int) string {
709789
return result.String()
710790
}
711791

792+
// applySearchFilter computes searchMatchIDs and searchAncIDs based on searchQuery.
793+
// It also auto-expands ancestors of matching items.
794+
func (m *Model) applySearchFilter() {
795+
if m.searchQuery == "" {
796+
m.searchMatchIDs = nil
797+
m.searchAncIDs = nil
798+
return
799+
}
800+
801+
query := strings.ToLower(m.searchQuery)
802+
m.searchMatchIDs = make(map[string]bool)
803+
m.searchAncIDs = make(map[string]bool)
804+
805+
// Walk tree items recursively looking for matches
806+
var walk func(items []*TreeItem)
807+
walk = func(items []*TreeItem) {
808+
for _, item := range items {
809+
if strings.Contains(strings.ToLower(item.Name), query) {
810+
m.searchMatchIDs[item.ID] = true
811+
// Collect and expand ancestors
812+
m.addAncestors(item.ParentID)
813+
}
814+
walk(item.Children)
815+
}
816+
}
817+
walk(m.treeItems)
818+
}
819+
820+
// addAncestors walks up the tree from parentID, adding each ancestor to searchAncIDs
821+
// and expanding them so they become visible.
822+
func (m *Model) addAncestors(parentID string) {
823+
if parentID == "" {
824+
return
825+
}
826+
if m.searchAncIDs[parentID] {
827+
return // already processed
828+
}
829+
m.searchAncIDs[parentID] = true
830+
m.expandedState[parentID] = true
831+
832+
// Find the parent item to continue up
833+
var findParent func(items []*TreeItem) string
834+
findParent = func(items []*TreeItem) string {
835+
for _, item := range items {
836+
if item.ID == parentID {
837+
return item.ParentID
838+
}
839+
if found := findParent(item.Children); found != "" {
840+
return found
841+
}
842+
}
843+
return ""
844+
}
845+
grandparentID := findParent(m.treeItems)
846+
if grandparentID != "" {
847+
m.addAncestors(grandparentID)
848+
}
849+
}
850+
712851
// rebuildItems rebuilds the flattened item list based on expanded state
713852
func (m *Model) rebuildItems() {
714853
m.treeItems = BuildTreeItems(m.roots, m.expandedState, m.inputURLs)
715854
m.visibleItems = FlattenVisibleItems(m.treeItems, m.expandedState)
716855

856+
// Apply search filter if active
857+
if m.searchQuery != "" && (m.searchMatchIDs != nil || m.searchAncIDs != nil) {
858+
m.visibleItems = FilterVisibleItems(m.visibleItems, m.searchMatchIDs, m.searchAncIDs)
859+
}
860+
717861
// Ensure cursor is valid
718862
if m.cursor >= len(m.visibleItems) {
719863
m.cursor = len(m.visibleItems) - 1
@@ -774,6 +918,49 @@ func (m *Model) openCurrentItem() {
774918
}
775919
}
776920

921+
// renderSearchBar renders the search input bar
922+
func (m Model) renderSearchBar(contentWidth int) string {
923+
// Format: │ / query█ N matches │
924+
prefix := SearchBarStyle.Render(" / ")
925+
query := SearchBarStyle.Render(m.searchQuery)
926+
cursor := ""
927+
if m.isSearching {
928+
cursor = SearchBarStyle.Render("█")
929+
}
930+
931+
// Count matches
932+
matchCount := len(m.searchMatchIDs)
933+
countStr := ""
934+
if m.searchQuery != "" {
935+
countStr = SearchCountStyle.Render(fmt.Sprintf("%d matches ", matchCount))
936+
}
937+
938+
// Calculate padding
939+
prefixWidth := 3 // " / "
940+
queryWidth := len(m.searchQuery)
941+
cursorWidth := 0
942+
if m.isSearching {
943+
cursorWidth = 1
944+
}
945+
countWidth := 0
946+
if countStr != "" {
947+
if matchCount >= 100 {
948+
countWidth = 12 // "NNN matches "
949+
} else if matchCount >= 10 {
950+
countWidth = 11 // "NN matches "
951+
} else {
952+
countWidth = 10 // "N matches "
953+
}
954+
}
955+
956+
padWidth := contentWidth - prefixWidth - queryWidth - cursorWidth - countWidth
957+
if padWidth < 1 {
958+
padWidth = 1
959+
}
960+
961+
return BorderStyle.Render("│") + prefix + query + cursor + strings.Repeat(" ", padWidth) + countStr + BorderStyle.Render("│")
962+
}
963+
777964
// renderLoadingView renders the loading progress display
778965
func (m Model) renderLoadingView() string {
779966
var b strings.Builder

0 commit comments

Comments
 (0)