mirror of
https://github.com/charmbracelet/bubbles.git
synced 2026-07-20 18:49:28 -06:00
feat(filepicker): add search/filter functionality
Add incremental search to the file picker component, allowing users to filter visible files by name. Press '/' to enter search mode, type to filter, Enter to confirm, Esc to cancel. Changes: - Add '/' key binding to enter search mode with a text input - Filter displayed files using case-insensitive substring matching - Show search bar while typing, filter status bar when confirmed - Navigation keys (j/k/g/G/pgup/pgdown) work on filtered list - Esc clears filter first, then navigates to parent directory - Entering a directory clears the active search - Add FilterFunc type and DefaultFilter for customizable filtering - Add Filter field to Model for user-supplied filter functions Fixes charmbracelet/bubbletea#1685
This commit is contained in:
parent
8585ef868b
commit
935efcd461
@ -12,6 +12,7 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/dustin/go-humanize"
|
||||
@ -23,8 +24,12 @@ func nextID() int {
|
||||
return int(atomic.AddInt64(&lastID, 1))
|
||||
}
|
||||
|
||||
// New returns a new filepicker model with default styling and key bindings.
|
||||
// New returns a new file picker model with default styling and key bindings.
|
||||
func New() Model {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = "/ "
|
||||
ti.CharLimit = 64
|
||||
|
||||
return Model{
|
||||
id: nextID(),
|
||||
CurrentDirectory: ".",
|
||||
@ -45,6 +50,8 @@ func New() Model {
|
||||
maxStack: newStack(),
|
||||
KeyMap: DefaultKeyMap(),
|
||||
Styles: DefaultStyles(),
|
||||
searchInput: ti,
|
||||
Filter: DefaultFilter,
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,6 +81,7 @@ type KeyMap struct {
|
||||
Back key.Binding
|
||||
Open key.Binding
|
||||
Select key.Binding
|
||||
Search key.Binding
|
||||
}
|
||||
|
||||
// DefaultKeyMap defines the default keybindings.
|
||||
@ -88,6 +96,7 @@ func DefaultKeyMap() KeyMap {
|
||||
Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")),
|
||||
Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")),
|
||||
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||||
Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,6 +132,21 @@ func DefaultStyles() Styles {
|
||||
}
|
||||
}
|
||||
|
||||
// FilterFunc is a function that returns matching file indices for a query.
|
||||
type FilterFunc func(query string, entries []os.DirEntry) []int
|
||||
|
||||
// DefaultFilter performs a case-insensitive substring match on file names.
|
||||
func DefaultFilter(query string, entries []os.DirEntry) []int {
|
||||
lower := strings.ToLower(query)
|
||||
var matches []int
|
||||
for i, entry := range entries {
|
||||
if strings.Contains(strings.ToLower(entry.Name()), lower) {
|
||||
matches = append(matches, i)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// Model represents a file picker.
|
||||
type Model struct {
|
||||
id int
|
||||
@ -159,6 +183,17 @@ type Model struct {
|
||||
|
||||
Cursor string
|
||||
Styles Styles
|
||||
|
||||
// Search/filter state
|
||||
searchInput textinput.Model
|
||||
searchActive bool // user is typing in the search bar
|
||||
filterApplied bool // a filter is active (search confirmed or still typing)
|
||||
filteredFiles []os.DirEntry
|
||||
filteredMap []int // maps filtered index -> original files index
|
||||
|
||||
// Filter is the function used to filter files. Defaults to case-insensitive
|
||||
// substring matching. Set this to customize filtering behavior.
|
||||
Filter FilterFunc
|
||||
}
|
||||
|
||||
type stack struct {
|
||||
@ -242,6 +277,56 @@ func (m Model) Init() tea.Cmd {
|
||||
return m.readDir(m.CurrentDirectory, m.ShowHidden)
|
||||
}
|
||||
|
||||
// visibleFiles returns the currently visible files (filtered or all).
|
||||
func (m Model) visibleFiles() []os.DirEntry {
|
||||
if m.filterApplied && len(m.filteredFiles) > 0 {
|
||||
return m.filteredFiles
|
||||
}
|
||||
return m.files
|
||||
}
|
||||
|
||||
// updateFilter recomputes the filtered files based on the current search query.
|
||||
func (m *Model) updateFilter() {
|
||||
query := m.searchInput.Value()
|
||||
if query == "" {
|
||||
m.filterApplied = false
|
||||
m.filteredFiles = nil
|
||||
m.filteredMap = nil
|
||||
return
|
||||
}
|
||||
|
||||
indices := m.Filter(query, m.files)
|
||||
m.filteredFiles = make([]os.DirEntry, 0, len(indices))
|
||||
m.filteredMap = make([]int, 0, len(indices))
|
||||
for _, idx := range indices {
|
||||
m.filteredFiles = append(m.filteredFiles, m.files[idx])
|
||||
m.filteredMap = append(m.filteredMap, idx)
|
||||
}
|
||||
m.filterApplied = true
|
||||
|
||||
// Reset selection if it's out of bounds
|
||||
if m.selected >= len(m.filteredFiles) {
|
||||
m.selected = max(0, len(m.filteredFiles)-1)
|
||||
}
|
||||
if m.minIdx > m.selected {
|
||||
m.minIdx = m.selected
|
||||
}
|
||||
m.maxIdx = m.minIdx + m.Height() - 1
|
||||
}
|
||||
|
||||
// resetSearch clears the search state and restores the full file list.
|
||||
func (m *Model) resetSearch() {
|
||||
m.searchActive = false
|
||||
m.filterApplied = false
|
||||
m.filteredFiles = nil
|
||||
m.filteredMap = nil
|
||||
m.searchInput.SetValue("")
|
||||
m.searchInput.Blur()
|
||||
m.selected = 0
|
||||
m.minIdx = 0
|
||||
m.maxIdx = m.Height() - 1
|
||||
}
|
||||
|
||||
// Update handles user interactions within the file picker model.
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
@ -251,25 +336,64 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
}
|
||||
m.files = msg.entries
|
||||
m.maxIdx = max(m.maxIdx, m.Height()-1)
|
||||
// Reapply filter if active
|
||||
if m.filterApplied {
|
||||
m.updateFilter()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
if m.AutoHeight {
|
||||
m.SetHeight(msg.Height - marginBottom)
|
||||
}
|
||||
m.maxIdx = m.Height() - 1
|
||||
case tea.KeyPressMsg:
|
||||
// When search is active, handle search-specific keys first
|
||||
if m.searchActive {
|
||||
var cmd tea.Cmd
|
||||
switch {
|
||||
case msg.String() == "esc":
|
||||
if m.filterApplied && m.searchInput.Value() != "" {
|
||||
// Filter was applied: clear everything
|
||||
m.resetSearch()
|
||||
} else {
|
||||
// No filter: just exit search mode
|
||||
m.searchActive = false
|
||||
m.searchInput.Blur()
|
||||
}
|
||||
return m, nil
|
||||
case msg.String() == "enter":
|
||||
// Confirm search: exit search mode but keep filter
|
||||
m.searchActive = false
|
||||
m.searchInput.Blur()
|
||||
return m, nil
|
||||
default:
|
||||
// Forward to text input
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
m.updateFilter()
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, m.KeyMap.Search):
|
||||
// Enter search mode
|
||||
m.searchActive = true
|
||||
m.searchInput.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.KeyMap.GoToTop):
|
||||
m.selected = 0
|
||||
m.minIdx = 0
|
||||
m.maxIdx = m.Height() - 1
|
||||
case key.Matches(msg, m.KeyMap.GoToLast):
|
||||
m.selected = len(m.files) - 1
|
||||
m.minIdx = len(m.files) - m.Height()
|
||||
m.maxIdx = len(m.files) - 1
|
||||
vis := m.visibleFiles()
|
||||
m.selected = len(vis) - 1
|
||||
m.minIdx = len(vis) - m.Height()
|
||||
m.maxIdx = len(vis) - 1
|
||||
case key.Matches(msg, m.KeyMap.Down):
|
||||
vis := m.visibleFiles()
|
||||
m.selected++
|
||||
if m.selected >= len(m.files) {
|
||||
m.selected = len(m.files) - 1
|
||||
if m.selected >= len(vis) {
|
||||
m.selected = len(vis) - 1
|
||||
}
|
||||
if m.selected > m.maxIdx {
|
||||
m.minIdx++
|
||||
@ -285,15 +409,16 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.maxIdx--
|
||||
}
|
||||
case key.Matches(msg, m.KeyMap.PageDown):
|
||||
vis := m.visibleFiles()
|
||||
m.selected += m.Height()
|
||||
if m.selected >= len(m.files) {
|
||||
m.selected = len(m.files) - 1
|
||||
if m.selected >= len(vis) {
|
||||
m.selected = len(vis) - 1
|
||||
}
|
||||
m.minIdx += m.Height()
|
||||
m.maxIdx += m.Height()
|
||||
|
||||
if m.maxIdx >= len(m.files) {
|
||||
m.maxIdx = len(m.files) - 1
|
||||
if m.maxIdx >= len(vis) {
|
||||
m.maxIdx = len(vis) - 1
|
||||
m.minIdx = m.maxIdx - m.Height()
|
||||
}
|
||||
case key.Matches(msg, m.KeyMap.PageUp):
|
||||
@ -309,6 +434,14 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.maxIdx = m.minIdx + m.Height()
|
||||
}
|
||||
case key.Matches(msg, m.KeyMap.Back):
|
||||
// Esc has layered behavior:
|
||||
// 1. If filter is applied, clear filter first
|
||||
// 2. Otherwise, go to parent directory
|
||||
if m.filterApplied {
|
||||
m.resetSearch()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.CurrentDirectory = filepath.Dir(m.CurrentDirectory)
|
||||
if m.selectedStack.Length() > 0 {
|
||||
m.selected, m.minIdx, m.maxIdx = m.popView()
|
||||
@ -319,11 +452,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
}
|
||||
return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
|
||||
case key.Matches(msg, m.KeyMap.Open):
|
||||
if len(m.files) == 0 {
|
||||
vis := m.visibleFiles()
|
||||
if len(vis) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
f := m.files[m.selected]
|
||||
f := vis[m.selected]
|
||||
info, err := f.Info()
|
||||
if err != nil {
|
||||
break
|
||||
@ -353,6 +487,14 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
break
|
||||
}
|
||||
|
||||
// Entering a directory: clear search
|
||||
if m.filterApplied {
|
||||
m.searchInput.SetValue("")
|
||||
m.filterApplied = false
|
||||
m.filteredFiles = nil
|
||||
m.filteredMap = nil
|
||||
}
|
||||
|
||||
m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name())
|
||||
m.pushView(m.selected, m.minIdx, m.maxIdx)
|
||||
m.selected = 0
|
||||
@ -366,12 +508,34 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
|
||||
// View returns the view of the file picker.
|
||||
func (m Model) View() string {
|
||||
if len(m.files) == 0 {
|
||||
return m.Styles.EmptyDirectory.Height(m.Height()).MaxHeight(m.Height()).String()
|
||||
}
|
||||
var s strings.Builder
|
||||
|
||||
for i, f := range m.files {
|
||||
// Show search bar when active
|
||||
if m.searchActive {
|
||||
s.WriteString(m.searchInput.View())
|
||||
s.WriteRune('\n')
|
||||
}
|
||||
|
||||
vis := m.visibleFiles()
|
||||
|
||||
// Show filter status when filter is applied but not actively typing
|
||||
if m.filterApplied && !m.searchActive && m.searchInput.Value() != "" {
|
||||
status := fmt.Sprintf(" filter: %s (%d/%d)", m.searchInput.Value(), len(vis), len(m.files))
|
||||
s.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(status))
|
||||
s.WriteRune('\n')
|
||||
}
|
||||
|
||||
if len(vis) == 0 {
|
||||
if m.filterApplied {
|
||||
noMatch := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("No matches.")
|
||||
s.WriteString(noMatch.Height(m.Height()).MaxHeight(m.Height()).String())
|
||||
} else {
|
||||
s.WriteString(m.Styles.EmptyDirectory.Height(m.Height()).MaxHeight(m.Height()).String())
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
|
||||
for i, f := range vis {
|
||||
if i < m.minIdx || i > m.maxIdx {
|
||||
continue
|
||||
}
|
||||
@ -464,7 +628,8 @@ func (m Model) DidSelectDisabledFile(msg tea.Msg) (bool, string) {
|
||||
}
|
||||
|
||||
func (m Model) didSelectFile(msg tea.Msg) (bool, string) {
|
||||
if len(m.files) == 0 {
|
||||
vis := m.visibleFiles()
|
||||
if len(vis) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
@ -476,7 +641,7 @@ func (m Model) didSelectFile(msg tea.Msg) (bool, string) {
|
||||
|
||||
// The key press was a selection, let's confirm whether the current file could
|
||||
// be selected or used for navigating deeper into the stack.
|
||||
f := m.files[m.selected]
|
||||
f := vis[m.selected]
|
||||
info, err := f.Info()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
@ -522,8 +687,9 @@ func (m Model) canSelect(file string) bool {
|
||||
|
||||
// HighlightedPath returns the path of the currently highlighted file or directory.
|
||||
func (m Model) HighlightedPath() string {
|
||||
if len(m.files) == 0 || m.selected < 0 || m.selected >= len(m.files) {
|
||||
vis := m.visibleFiles()
|
||||
if len(vis) == 0 || m.selected < 0 || m.selected >= len(vis) {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(m.CurrentDirectory, m.files[m.selected].Name())
|
||||
return filepath.Join(m.CurrentDirectory, vis[m.selected].Name())
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user