|
4 | 4 | "fmt" |
5 | 5 | "strings" |
6 | 6 | "time" |
| 7 | + "unicode/utf8" |
7 | 8 |
|
8 | 9 | "github.com/charmbracelet/bubbles/key" |
9 | 10 | "github.com/charmbracelet/bubbles/spinner" |
@@ -86,6 +87,11 @@ type Model struct { |
86 | 87 | openPerfettoFunc func() |
87 | 88 | // Mouse mode state |
88 | 89 | 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) |
89 | 95 | } |
90 | 96 |
|
91 | 97 | // ReloadFunc is the function signature for reloading data |
@@ -341,6 +347,63 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { |
341 | 347 | return m, nil |
342 | 348 | } |
343 | 349 |
|
| 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 | + |
344 | 407 | switch { |
345 | 408 | case key.Matches(msg, m.keys.Quit): |
346 | 409 | return m, tea.Quit |
@@ -422,6 +485,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { |
422 | 485 | } |
423 | 486 | return m, tea.DisableMouse |
424 | 487 |
|
| 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 | + |
425 | 495 | case key.Matches(msg, m.keys.Help): |
426 | 496 | m.showHelpModal = true |
427 | 497 | return m, nil |
@@ -532,6 +602,10 @@ func (m Model) View() string { |
532 | 602 | // Calculate available height for items |
533 | 603 | headerLines := 9 // header box (6 lines) + 1 newline + time axis + 1 blank line |
534 | 604 | 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 | + } |
535 | 609 | availableHeight := height - headerLines - footerLines |
536 | 610 | if availableHeight < 1 { |
537 | 611 | availableHeight = 10 |
@@ -559,6 +633,12 @@ func (m Model) View() string { |
559 | 633 | b.WriteString(blankLine) |
560 | 634 | b.WriteString("\n") |
561 | 635 |
|
| 636 | + // Search bar (between blank line and content) |
| 637 | + if searchActive { |
| 638 | + b.WriteString(m.renderSearchBar(contentWidth)) |
| 639 | + b.WriteString("\n") |
| 640 | + } |
| 641 | + |
562 | 642 | // Determine scroll window |
563 | 643 | startIdx := 0 |
564 | 644 | endIdx := totalItems |
@@ -709,11 +789,75 @@ func addHorizontalPadding(content string, pad int) string { |
709 | 789 | return result.String() |
710 | 790 | } |
711 | 791 |
|
| 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 | + |
712 | 851 | // rebuildItems rebuilds the flattened item list based on expanded state |
713 | 852 | func (m *Model) rebuildItems() { |
714 | 853 | m.treeItems = BuildTreeItems(m.roots, m.expandedState, m.inputURLs) |
715 | 854 | m.visibleItems = FlattenVisibleItems(m.treeItems, m.expandedState) |
716 | 855 |
|
| 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 | + |
717 | 861 | // Ensure cursor is valid |
718 | 862 | if m.cursor >= len(m.visibleItems) { |
719 | 863 | m.cursor = len(m.visibleItems) - 1 |
@@ -774,6 +918,49 @@ func (m *Model) openCurrentItem() { |
774 | 918 | } |
775 | 919 | } |
776 | 920 |
|
| 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 | + |
777 | 964 | // renderLoadingView renders the loading progress display |
778 | 965 | func (m Model) renderLoadingView() string { |
779 | 966 | var b strings.Builder |
|
0 commit comments