mirror of
https://github.com/sst/opencode.git
synced 2025-08-23 22:44:08 +00:00
255 lines
6.1 KiB
Go
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()
|
|
}
|