Files
mend/internal/ui/search/model.go

225 lines
4.9 KiB
Go

/*
search ui model
*/
package search
import (
"strings"
"mend/internal/search"
"mend/styles"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type SearchView struct {
input textinput.Model
engine *search.SearchEngine
results []search.SearchResult
selectedIndex int
width int
height int
active bool
}
func NewSearchView(engine *search.SearchEngine) *SearchView {
ti := textinput.New()
ti.Placeholder = "Search files..."
ti.CharLimit = 256
ti.Width = 50 // TODO: make this dynamic, Note that there is a context len on the engine as well
ti.Focus()
return &SearchView{
input: ti,
engine: engine,
results: make([]search.SearchResult, 0),
selectedIndex: 0,
}
}
// SearchSelectMsg is sent when user selects a search result
type SearchSelectMsg struct {
Path string
IsFolder bool
}
// SearchCancelMsg is sent when user cancels search
type SearchCancelMsg struct{}
func (v *SearchView) Init() tea.Cmd {
return textinput.Blink
}
func (v *SearchView) IsActive() bool {
return v.active
}
func (v *SearchView) Activate() tea.Cmd {
v.active = true
v.input.SetValue("")
v.results = nil
v.selectedIndex = 0
v.input.Focus()
return textinput.Blink
}
func (v *SearchView) Deactivate() {
v.active = false
v.input.Blur()
}
func (v *SearchView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.width = msg.Width
v.height = msg.Height
v.input.Width = msg.Width - 4
return v, nil
case tea.KeyMsg:
// operational events
switch msg.String() {
case "esc", "q", "ctrl+c", "ctrl+d", "ctrl+q":
v.Deactivate()
return v, func() tea.Msg { return SearchCancelMsg{} }
case "enter":
if len(v.results) > 0 && v.selectedIndex < len(v.results) {
result := v.results[v.selectedIndex]
v.Deactivate()
return v, func() tea.Msg {
return SearchSelectMsg{Path: result.Path, IsFolder: result.IsFolder}
}
}
return v, nil
case "up":
if v.selectedIndex > 0 {
v.selectedIndex--
}
return v, nil
case "down":
if v.selectedIndex < len(v.results)-1 {
v.selectedIndex++
}
return v, nil
}
// this is now for text input
var cmd tea.Cmd
oldValue := v.input.Value()
v.input, cmd = v.input.Update(msg)
// If query changed, re-search
if v.input.Value() != oldValue {
v.results = v.engine.Search(v.input.Value())
v.selectedIndex = 0
}
return v, cmd
}
return v, nil
}
func (v *SearchView) View() string {
if !v.active {
return ""
}
if v.engine.IsIndexing() {
return " Still indexing..."
}
noResultsStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Italic(true).
Padding(0, 2)
if v.input.Value() == "" {
return noResultsStyle.Render("Type a query to search")
}
var b strings.Builder
// Header with input
inputStyle := lipgloss.NewStyle().
Padding(0, 1).
Width(v.width - 4)
b.WriteString(inputStyle.Render(v.input.View()))
b.WriteString("\n")
// Results
if len(v.results) == 0 {
b.WriteString(noResultsStyle.Render("No results found"))
} else {
maxVisible := max(1, v.height-6) // 6 is kinda arbitrary, I just do a good enough offset for text box
// Calculate visible range, this handling next is bit buggy
// also TODO: i don't highlight if there are more results at the end
startIdx := 0
if v.selectedIndex >= maxVisible {
startIdx = v.selectedIndex - maxVisible + 1
}
endIdx := startIdx + maxVisible
if endIdx > len(v.results) {
endIdx = len(v.results)
}
for i := startIdx; i < endIdx; i++ {
result := v.results[i]
isSelected := i == v.selectedIndex
// Result line style
lineStyle := lipgloss.NewStyle().Padding(0, 2)
if isSelected {
lineStyle = lineStyle.
Background(lipgloss.Color("237")). // TODO: move to styles, I need to unify stules too
Foreground(styles.Highlight).
Bold(true)
}
// Build the display line with icon
var icon string
var path string
if result.IsFolder {
if isSelected {
icon = styles.FolderIcon
} else {
icon = lipgloss.NewStyle().Foreground(styles.FolderBlue).Render(styles.FolderIcon)
}
path = result.RelativePath + "/"
} else {
if isSelected {
icon = styles.FileIcon
} else {
icon = lipgloss.NewStyle().Foreground(styles.FileGreen).Render(styles.FileIcon)
}
path = result.RelativePath
if result.Snippet != "" && !isSelected {
snippetStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
path += snippetStyle.Render(" " + result.Snippet)
} else if result.Snippet != "" {
path += " " + result.Snippet
}
}
line := icon + " " + path
b.WriteString(lineStyle.Render(line))
b.WriteString("\n")
}
}
// Full screen container
containerStyle := lipgloss.NewStyle().
Width(v.width).
Height(v.height).
Padding(1, 2)
return containerStyle.Render(b.String())
}