225 lines
4.9 KiB
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())
|
|
}
|