opencode/packages/tui/internal/components/dialog/search.go
2025-08-07 13:23:58 -05:00

255 lines
6.1 KiB
Go

package dialog
import (
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
// SearchQueryChangedMsg is emitted when the search query changes
type SearchQueryChangedMsg struct {
Query string
}
// SearchSelectionMsg is emitted when an item is selected
type SearchSelectionMsg struct {
Item any
Index int
}
// SearchCancelledMsg is emitted when the search is cancelled
type SearchCancelledMsg struct{}
// SearchRemoveItemMsg is emitted when Ctrl+X is pressed to remove an item
type SearchRemoveItemMsg struct {
Item any
Index int
}
// SearchDialog is a reusable component that combines a text input with a list
type SearchDialog struct {
textInput textinput.Model
list list.List[list.Item]
width int
height int
focused bool
}
type searchKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Escape key.Binding
Remove key.Binding
}
var searchKeys = searchKeyMap{
Up: key.NewBinding(
key.WithKeys("up", "ctrl+p"),
key.WithHelp("↑", "previous item"),
),
Down: key.NewBinding(
key.WithKeys("down", "ctrl+n"),
key.WithHelp("↓", "next item"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
Remove: key.NewBinding(
key.WithKeys("ctrl+x"),
key.WithHelp("ctrl+x", "remove from recent"),
),
}
// NewSearchDialog creates a new SearchDialog
func NewSearchDialog(placeholder string, maxVisibleHeight int) *SearchDialog {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
textColor := t.Text()
textMutedColor := t.TextMuted()
ti := textinput.New()
ti.Placeholder = placeholder
ti.Styles.Blurred.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ti.Styles.Blurred.Text = styles.NewStyle().
Foreground(textColor).
Background(bgColor).
Lipgloss()
ti.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ti.Styles.Focused.Text = styles.NewStyle().
Foreground(textColor).
Background(bgColor).
Lipgloss()
ti.Styles.Focused.Prompt = styles.NewStyle().
Background(bgColor).
Lipgloss()
ti.Styles.Cursor.Color = t.Primary()
ti.VirtualCursor = true
ti.Prompt = " "
ti.CharLimit = -1
ti.Focus()
emptyList := list.NewListComponent(
list.WithItems([]list.Item{}),
list.WithMaxVisibleHeight[list.Item](maxVisibleHeight),
list.WithFallbackMessage[list.Item](" No items"),
list.WithAlphaNumericKeys[list.Item](false),
list.WithRenderFunc(
func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
return item.Render(selected, width, baseStyle)
},
),
list.WithSelectableFunc(func(item list.Item) bool {
return item.Selectable()
}),
)
return &SearchDialog{
textInput: ti,
list: emptyList,
focused: true,
}
}
func (s *SearchDialog) Init() tea.Cmd {
return textinput.Blink
}
func (s *SearchDialog) updateTextInput(msg tea.Msg) []tea.Cmd {
var cmds []tea.Cmd
oldValue := s.textInput.Value()
var cmd tea.Cmd
s.textInput, cmd = s.textInput.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
if newValue := s.textInput.Value(); newValue != oldValue {
cmds = append(cmds, func() tea.Msg {
return SearchQueryChangedMsg{Query: newValue}
})
}
return cmds
}
func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.PasteMsg, tea.ClipboardMsg:
cmds = append(cmds, s.updateTextInput(msg)...)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
value := s.textInput.Value()
if value == "" {
return s, nil
}
s.textInput.Reset()
cmds = append(cmds, func() tea.Msg {
return SearchQueryChangedMsg{Query: ""}
})
}
switch {
case key.Matches(msg, searchKeys.Escape):
return s, func() tea.Msg { return SearchCancelledMsg{} }
case key.Matches(msg, searchKeys.Enter):
if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
return s, func() tea.Msg {
return SearchSelectionMsg{Item: selectedItem, Index: idx}
}
}
case key.Matches(msg, searchKeys.Remove):
if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
return s, func() tea.Msg {
return SearchRemoveItemMsg{Item: selectedItem, Index: idx}
}
}
case key.Matches(msg, searchKeys.Up):
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[list.Item])
if cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, searchKeys.Down):
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[list.Item])
if cmd != nil {
cmds = append(cmds, cmd)
}
default:
cmds = append(cmds, s.updateTextInput(msg)...)
}
}
return s, tea.Batch(cmds...)
}
func (s *SearchDialog) View() string {
s.list.SetMaxWidth(s.width)
listView := s.list.View()
listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleHeight(), lipgloss.Top, listView)
textinput := s.textInput.View()
return textinput + "\n\n" + listView
}
// SetWidth sets the width of the search dialog
func (s *SearchDialog) SetWidth(width int) {
s.width = width
s.textInput.SetWidth(width - 2) // Account for padding and borders
}
// SetHeight sets the height of the search dialog
func (s *SearchDialog) SetHeight(height int) {
s.height = height
}
// SetItems updates the list items
func (s *SearchDialog) SetItems(items []list.Item) {
s.list.SetItems(items)
}
// GetQuery returns the current search query
func (s *SearchDialog) GetQuery() string {
return s.textInput.Value()
}
// SetQuery sets the search query
func (s *SearchDialog) SetQuery(query string) {
s.textInput.SetValue(query)
}
// Focus focuses the search dialog
func (s *SearchDialog) Focus() {
s.focused = true
s.textInput.Focus()
}
// Blur removes focus from the search dialog
func (s *SearchDialog) Blur() {
s.focused = false
s.textInput.Blur()
}