search.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. package dialog
  2. import (
  3. "github.com/charmbracelet/bubbles/v2/key"
  4. "github.com/charmbracelet/bubbles/v2/textinput"
  5. tea "github.com/charmbracelet/bubbletea/v2"
  6. "github.com/charmbracelet/lipgloss/v2"
  7. "github.com/sst/opencode/internal/components/list"
  8. "github.com/sst/opencode/internal/styles"
  9. "github.com/sst/opencode/internal/theme"
  10. )
  11. // SearchQueryChangedMsg is emitted when the search query changes
  12. type SearchQueryChangedMsg struct {
  13. Query string
  14. }
  15. // SearchSelectionMsg is emitted when an item is selected
  16. type SearchSelectionMsg struct {
  17. Item any
  18. Index int
  19. }
  20. // SearchCancelledMsg is emitted when the search is cancelled
  21. type SearchCancelledMsg struct{}
  22. // SearchRemoveItemMsg is emitted when Ctrl+X is pressed to remove an item
  23. type SearchRemoveItemMsg struct {
  24. Item any
  25. Index int
  26. }
  27. // SearchDialog is a reusable component that combines a text input with a list
  28. type SearchDialog struct {
  29. textInput textinput.Model
  30. list list.List[list.Item]
  31. width int
  32. height int
  33. focused bool
  34. }
  35. type searchKeyMap struct {
  36. Up key.Binding
  37. Down key.Binding
  38. Enter key.Binding
  39. Escape key.Binding
  40. Remove key.Binding
  41. }
  42. var searchKeys = searchKeyMap{
  43. Up: key.NewBinding(
  44. key.WithKeys("up", "ctrl+p"),
  45. key.WithHelp("↑", "previous item"),
  46. ),
  47. Down: key.NewBinding(
  48. key.WithKeys("down", "ctrl+n"),
  49. key.WithHelp("↓", "next item"),
  50. ),
  51. Enter: key.NewBinding(
  52. key.WithKeys("enter"),
  53. key.WithHelp("enter", "select"),
  54. ),
  55. Escape: key.NewBinding(
  56. key.WithKeys("esc"),
  57. key.WithHelp("esc", "cancel"),
  58. ),
  59. Remove: key.NewBinding(
  60. key.WithKeys("ctrl+x"),
  61. key.WithHelp("ctrl+x", "remove from recent"),
  62. ),
  63. }
  64. // NewSearchDialog creates a new SearchDialog
  65. func NewSearchDialog(placeholder string, maxVisibleHeight int) *SearchDialog {
  66. t := theme.CurrentTheme()
  67. bgColor := t.BackgroundElement()
  68. textColor := t.Text()
  69. textMutedColor := t.TextMuted()
  70. ti := textinput.New()
  71. ti.Placeholder = placeholder
  72. ti.Styles.Blurred.Placeholder = styles.NewStyle().
  73. Foreground(textMutedColor).
  74. Background(bgColor).
  75. Lipgloss()
  76. ti.Styles.Blurred.Text = styles.NewStyle().
  77. Foreground(textColor).
  78. Background(bgColor).
  79. Lipgloss()
  80. ti.Styles.Focused.Placeholder = styles.NewStyle().
  81. Foreground(textMutedColor).
  82. Background(bgColor).
  83. Lipgloss()
  84. ti.Styles.Focused.Text = styles.NewStyle().
  85. Foreground(textColor).
  86. Background(bgColor).
  87. Lipgloss()
  88. ti.Styles.Focused.Prompt = styles.NewStyle().
  89. Background(bgColor).
  90. Lipgloss()
  91. ti.Styles.Cursor.Color = t.Primary()
  92. ti.VirtualCursor = true
  93. ti.Prompt = " "
  94. ti.CharLimit = -1
  95. ti.Focus()
  96. emptyList := list.NewListComponent(
  97. list.WithItems([]list.Item{}),
  98. list.WithMaxVisibleHeight[list.Item](maxVisibleHeight),
  99. list.WithFallbackMessage[list.Item](" No items"),
  100. list.WithAlphaNumericKeys[list.Item](false),
  101. list.WithRenderFunc(
  102. func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
  103. return item.Render(selected, width, baseStyle)
  104. },
  105. ),
  106. list.WithSelectableFunc(func(item list.Item) bool {
  107. return item.Selectable()
  108. }),
  109. )
  110. return &SearchDialog{
  111. textInput: ti,
  112. list: emptyList,
  113. focused: true,
  114. }
  115. }
  116. func (s *SearchDialog) Init() tea.Cmd {
  117. return textinput.Blink
  118. }
  119. func (s *SearchDialog) updateTextInput(msg tea.Msg) []tea.Cmd {
  120. var cmds []tea.Cmd
  121. oldValue := s.textInput.Value()
  122. var cmd tea.Cmd
  123. s.textInput, cmd = s.textInput.Update(msg)
  124. if cmd != nil {
  125. cmds = append(cmds, cmd)
  126. }
  127. if newValue := s.textInput.Value(); newValue != oldValue {
  128. cmds = append(cmds, func() tea.Msg {
  129. return SearchQueryChangedMsg{Query: newValue}
  130. })
  131. }
  132. return cmds
  133. }
  134. func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  135. var cmds []tea.Cmd
  136. switch msg := msg.(type) {
  137. case tea.PasteMsg, tea.ClipboardMsg:
  138. cmds = append(cmds, s.updateTextInput(msg)...)
  139. case tea.KeyMsg:
  140. switch msg.String() {
  141. case "ctrl+c":
  142. value := s.textInput.Value()
  143. if value == "" {
  144. return s, nil
  145. }
  146. s.textInput.Reset()
  147. cmds = append(cmds, func() tea.Msg {
  148. return SearchQueryChangedMsg{Query: ""}
  149. })
  150. }
  151. switch {
  152. case key.Matches(msg, searchKeys.Escape):
  153. return s, func() tea.Msg { return SearchCancelledMsg{} }
  154. case key.Matches(msg, searchKeys.Enter):
  155. if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
  156. return s, func() tea.Msg {
  157. return SearchSelectionMsg{Item: selectedItem, Index: idx}
  158. }
  159. }
  160. case key.Matches(msg, searchKeys.Remove):
  161. if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
  162. return s, func() tea.Msg {
  163. return SearchRemoveItemMsg{Item: selectedItem, Index: idx}
  164. }
  165. }
  166. case key.Matches(msg, searchKeys.Up):
  167. var cmd tea.Cmd
  168. listModel, cmd := s.list.Update(msg)
  169. s.list = listModel.(list.List[list.Item])
  170. if cmd != nil {
  171. cmds = append(cmds, cmd)
  172. }
  173. case key.Matches(msg, searchKeys.Down):
  174. var cmd tea.Cmd
  175. listModel, cmd := s.list.Update(msg)
  176. s.list = listModel.(list.List[list.Item])
  177. if cmd != nil {
  178. cmds = append(cmds, cmd)
  179. }
  180. default:
  181. cmds = append(cmds, s.updateTextInput(msg)...)
  182. }
  183. }
  184. return s, tea.Batch(cmds...)
  185. }
  186. func (s *SearchDialog) View() string {
  187. s.list.SetMaxWidth(s.width)
  188. listView := s.list.View()
  189. listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleHeight(), lipgloss.Top, listView)
  190. textinput := s.textInput.View()
  191. return textinput + "\n\n" + listView
  192. }
  193. // SetWidth sets the width of the search dialog
  194. func (s *SearchDialog) SetWidth(width int) {
  195. s.width = width
  196. s.textInput.SetWidth(width - 2) // Account for padding and borders
  197. }
  198. // SetHeight sets the height of the search dialog
  199. func (s *SearchDialog) SetHeight(height int) {
  200. s.height = height
  201. }
  202. // SetItems updates the list items
  203. func (s *SearchDialog) SetItems(items []list.Item) {
  204. s.list.SetItems(items)
  205. }
  206. // GetQuery returns the current search query
  207. func (s *SearchDialog) GetQuery() string {
  208. return s.textInput.Value()
  209. }
  210. // SetQuery sets the search query
  211. func (s *SearchDialog) SetQuery(query string) {
  212. s.textInput.SetValue(query)
  213. }
  214. // Focus focuses the search dialog
  215. func (s *SearchDialog) Focus() {
  216. s.focused = true
  217. s.textInput.Focus()
  218. }
  219. // Blur removes focus from the search dialog
  220. func (s *SearchDialog) Blur() {
  221. s.focused = false
  222. s.textInput.Blur()
  223. }