feat: ctrl f to search ( new files not indexed )
This commit is contained in:
@@ -400,6 +400,42 @@ func (t *FsTree) ToggleSelectedExpand() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectByPath finds and selects a node by its file system path
|
||||
func (t *FsTree) SelectByPath(path string) bool {
|
||||
node := t.findNodeByPath(t.Root, path)
|
||||
if node != nil {
|
||||
// Expand all parent folders to make the node visible
|
||||
parent := node.Parent
|
||||
for parent != nil {
|
||||
if parent.Type == FolderNode && !parent.Expanded {
|
||||
parent.Expanded = true
|
||||
}
|
||||
parent = parent.Parent
|
||||
}
|
||||
t.SelectedNode = node
|
||||
t.BuildLines()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tree walks and find where patch matches, think can easily be better
|
||||
// if I cache it in an associative continer but this is fine too
|
||||
func (t *FsTree) findNodeByPath(node *FsNode, path string) *FsNode {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if node.Path == path {
|
||||
return node
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
if found := t.findNodeByPath(child, path); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) {
|
||||
if node == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user