feat: ctrl f to search ( new files not indexed )
This commit is contained in:
@@ -0,0 +1,225 @@
|
|||||||
|
/*
|
||||||
|
Simple exact string match engine.
|
||||||
|
*/
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultContextLen = 40
|
||||||
|
|
||||||
|
// SearchResult represents a single search match
|
||||||
|
type SearchResult struct {
|
||||||
|
Path string
|
||||||
|
RelativePath string
|
||||||
|
FileName string
|
||||||
|
Snippet string
|
||||||
|
Score int
|
||||||
|
IsFolder bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchEngine struct {
|
||||||
|
files []fileEntry
|
||||||
|
isIndexing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal struct used while indexing
|
||||||
|
type fileEntry struct {
|
||||||
|
path string
|
||||||
|
relativePath string
|
||||||
|
fileName string
|
||||||
|
content string
|
||||||
|
isFolder bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSearchEngine() *SearchEngine {
|
||||||
|
return &SearchEngine{
|
||||||
|
files: make([]fileEntry, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SearchEngine) IsIndexing() bool {
|
||||||
|
return e.isIndexing
|
||||||
|
}
|
||||||
|
|
||||||
|
// tea cmd for indexing
|
||||||
|
func StartIndexing(engine *SearchEngine, rootPath string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
engine.isIndexing = true
|
||||||
|
engine.files = make([]fileEntry, 0)
|
||||||
|
|
||||||
|
// file walker
|
||||||
|
filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // skip errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hidden files/folders
|
||||||
|
if strings.HasPrefix(info.Name(), ".") {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip root path itself
|
||||||
|
if path == rootPath {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute relative path from root
|
||||||
|
relPath, _ := filepath.Rel(rootPath, path)
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
// Index folder
|
||||||
|
engine.files = append(engine.files, fileEntry{
|
||||||
|
path: path,
|
||||||
|
relativePath: relPath,
|
||||||
|
fileName: info.Name(),
|
||||||
|
content: "",
|
||||||
|
isFolder: true,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// file handling
|
||||||
|
// Only index .md files
|
||||||
|
if !strings.HasSuffix(info.Name(), ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := strings.TrimSuffix(info.Name(), ".md")
|
||||||
|
relPathDisplay := strings.TrimSuffix(relPath, ".md")
|
||||||
|
|
||||||
|
engine.files = append(engine.files, fileEntry{
|
||||||
|
path: path,
|
||||||
|
relativePath: relPathDisplay,
|
||||||
|
fileName: fileName,
|
||||||
|
content: strings.ToLower(string(content)),
|
||||||
|
isFolder: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.isIndexing = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SearchEngine) Search(query string) []SearchResult {
|
||||||
|
if query == "" || e.isIndexing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// to make case insensitive, we don't do it while reading so that
|
||||||
|
// display is case correct
|
||||||
|
queryLower := strings.ToLower(query)
|
||||||
|
results := make([]SearchResult, 0)
|
||||||
|
|
||||||
|
for _, file := range e.files {
|
||||||
|
fileNameLower := strings.ToLower(file.fileName)
|
||||||
|
contentLower := strings.ToLower(file.content)
|
||||||
|
|
||||||
|
// Check title matches, covers fodler name as well
|
||||||
|
matchPos := strings.Index(fileNameLower, queryLower)
|
||||||
|
if matchPos >= 0 {
|
||||||
|
score := 100 // base title match score
|
||||||
|
if fileNameLower == queryLower {
|
||||||
|
score += 100 // exact match bonus
|
||||||
|
} else if isWordMatch(queryLower, fileNameLower, matchPos) {
|
||||||
|
score += 50 // word boundary bonus
|
||||||
|
}
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Path: file.path,
|
||||||
|
RelativePath: file.relativePath,
|
||||||
|
FileName: file.fileName,
|
||||||
|
Snippet: "",
|
||||||
|
Score: score,
|
||||||
|
IsFolder: file.isFolder,
|
||||||
|
})
|
||||||
|
continue // else double-count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content matches on files
|
||||||
|
if !file.isFolder {
|
||||||
|
matchPos := strings.Index(contentLower, queryLower)
|
||||||
|
if matchPos >= 0 {
|
||||||
|
score := 10 // base content match score
|
||||||
|
if isWordMatch(queryLower, contentLower, matchPos) {
|
||||||
|
score += 20 // word boundary bonus
|
||||||
|
}
|
||||||
|
snippet := extractSnippet(file.content, matchPos, defaultContextLen) // todo: form window width
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Path: file.path,
|
||||||
|
RelativePath: file.relativePath,
|
||||||
|
FileName: file.fileName,
|
||||||
|
Snippet: snippet,
|
||||||
|
Score: score,
|
||||||
|
IsFolder: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(results, func(a, b SearchResult) int {
|
||||||
|
return b.Score - a.Score
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWordMatch checks if query appears as a complete word in target
|
||||||
|
func isWordMatch(query, target string, indexInTarget int) bool {
|
||||||
|
// Go language note: these ^ strings are not getting copied again like C++, they are just references
|
||||||
|
isSpace := func(pos int) bool {
|
||||||
|
return pos < 0 || pos >= len(target) || target[pos] == ' ' || target[pos] == '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSpace(indexInTarget-1) && isSpace(indexInTarget+len(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSnippet extracts context around a match position
|
||||||
|
func extractSnippet(content string, pos, contextLen int) string {
|
||||||
|
start := pos - contextLen
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := pos + contextLen
|
||||||
|
if end > len(content) {
|
||||||
|
end = len(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust to word boundaries
|
||||||
|
for start > 0 && content[start] != ' ' && content[start] != '\n' {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
for end < len(content) && content[end] != ' ' && content[end] != '\n' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
|
||||||
|
snippet := strings.TrimSpace(content[start:end])
|
||||||
|
snippet = strings.ReplaceAll(snippet, "\n", " ")
|
||||||
|
|
||||||
|
// Add ellipsis
|
||||||
|
prefix := ""
|
||||||
|
suffix := ""
|
||||||
|
if start > 0 {
|
||||||
|
prefix = "..."
|
||||||
|
}
|
||||||
|
if end < len(content) {
|
||||||
|
suffix = "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix + snippet + suffix
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// THIS FILE IS AYE EYE GENERATED
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsWordMatch(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
target string
|
||||||
|
query string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"exact word", "hello world", "hello", true},
|
||||||
|
{"word in middle", "the hello world", "hello", true},
|
||||||
|
{"word at end", "say hello", "hello", true},
|
||||||
|
{"partial match", "helloworld", "hello", false},
|
||||||
|
{"suffix match", "worldhello", "hello", false},
|
||||||
|
{"underscore is word char", "my_hello_world", "hello", false}, // underscore joins words
|
||||||
|
{"no match", "goodbye", "hello", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
idx := strings.Index(tt.target, tt.query)
|
||||||
|
if idx == -1 {
|
||||||
|
if tt.want {
|
||||||
|
t.Errorf("isWordMatch(%q, %q) expected true but query not found", tt.target, tt.query)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got := isWordMatch(tt.query, tt.target, idx)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isWordMatch(%q, %q, %d) = %v, want %v", tt.query, tt.target, idx, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchEngine(t *testing.T) {
|
||||||
|
// Create temp directory with test files
|
||||||
|
tmpDir, err := os.MkdirTemp("", "search_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
engine := NewSearchEngine()
|
||||||
|
|
||||||
|
// Manually add files for testing
|
||||||
|
engine.files = append(engine.files, fileEntry{
|
||||||
|
path: filepath.Join(tmpDir, "folder1", "notes.md"),
|
||||||
|
relativePath: "folder1/notes",
|
||||||
|
fileName: "notes",
|
||||||
|
content: "Hello world test content",
|
||||||
|
})
|
||||||
|
engine.files = append(engine.files, fileEntry{
|
||||||
|
path: filepath.Join(tmpDir, "folder1", "ideas.md"),
|
||||||
|
relativePath: "folder1/ideas",
|
||||||
|
fileName: "ideas",
|
||||||
|
content: "Some ideas here",
|
||||||
|
})
|
||||||
|
engine.files = append(engine.files, fileEntry{
|
||||||
|
path: filepath.Join(tmpDir, "readme.md"),
|
||||||
|
relativePath: "readme",
|
||||||
|
fileName: "readme",
|
||||||
|
content: "This is the readme",
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("search by title exact", func(t *testing.T) {
|
||||||
|
results := engine.Search("notes")
|
||||||
|
if len(results) == 0 {
|
||||||
|
t.Error("expected at least one result for 'notes'")
|
||||||
|
}
|
||||||
|
if len(results) > 0 && results[0].FileName != "notes" {
|
||||||
|
t.Errorf("expected first result to be 'notes', got '%s'", results[0].FileName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("search by title partial", func(t *testing.T) {
|
||||||
|
results := engine.Search("not")
|
||||||
|
if len(results) == 0 {
|
||||||
|
t.Error("expected at least one result for 'not'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("search by content", func(t *testing.T) {
|
||||||
|
results := engine.Search("hello")
|
||||||
|
if len(results) == 0 {
|
||||||
|
t.Error("expected at least one result for 'hello'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("word match scores higher", func(t *testing.T) {
|
||||||
|
// "world" is a word in content
|
||||||
|
results := engine.Search("world")
|
||||||
|
if len(results) == 0 {
|
||||||
|
t.Error("expected at least one result")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no results", func(t *testing.T) {
|
||||||
|
results := engine.Search("xyznonexistent")
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Errorf("expected no results, got %d", len(results))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty query", func(t *testing.T) {
|
||||||
|
results := engine.Search("")
|
||||||
|
if results != nil {
|
||||||
|
t.Errorf("expected nil for empty query, got %v", results)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractSnippet(t *testing.T) {
|
||||||
|
content := "This is the start of a long piece of content that has a match somewhere in the middle."
|
||||||
|
|
||||||
|
snippet := extractSnippet(content, 40, 20)
|
||||||
|
if snippet == "" {
|
||||||
|
t.Error("expected non-empty snippet")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -400,6 +400,42 @@ func (t *FsTree) ToggleSelectedExpand() error {
|
|||||||
return nil
|
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) {
|
func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) {
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return
|
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())
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
|
"mend/internal/search"
|
||||||
"mend/internal/ui/fstree"
|
"mend/internal/ui/fstree"
|
||||||
"mend/internal/ui/note"
|
"mend/internal/ui/note"
|
||||||
|
uisearch "mend/internal/ui/search"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -37,6 +39,10 @@ type model struct {
|
|||||||
textInput textinput.Model
|
textInput textinput.Model
|
||||||
inputMode bool
|
inputMode bool
|
||||||
pendingAction fstree.FsActionType
|
pendingAction fstree.FsActionType
|
||||||
|
// search
|
||||||
|
searchEngine *search.SearchEngine
|
||||||
|
searchView *uisearch.SearchView
|
||||||
|
searchMode bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(rootPath string) *model {
|
func NewModel(rootPath string) *model {
|
||||||
@@ -44,6 +50,8 @@ func NewModel(rootPath string) *model {
|
|||||||
ti.CharLimit = 156
|
ti.CharLimit = 156
|
||||||
ti.Width = 30
|
ti.Width = 30
|
||||||
|
|
||||||
|
searchEngine := search.NewSearchEngine()
|
||||||
|
|
||||||
return &model{
|
return &model{
|
||||||
rootPath: rootPath,
|
rootPath: rootPath,
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -51,6 +59,8 @@ func NewModel(rootPath string) *model {
|
|||||||
showStatusBar: false,
|
showStatusBar: false,
|
||||||
showSidebar: true,
|
showSidebar: true,
|
||||||
textInput: ti,
|
textInput: ti,
|
||||||
|
searchEngine: searchEngine,
|
||||||
|
searchView: uisearch.NewSearchView(searchEngine),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,8 +147,25 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
Height: m.contentHeight,
|
Height: m.contentHeight,
|
||||||
Width: m.fsTreeWidth,
|
Width: m.fsTreeWidth,
|
||||||
})
|
})
|
||||||
|
// Start background indexing
|
||||||
|
indexCmd := search.StartIndexing(m.searchEngine, m.tree.Root.Path)
|
||||||
|
return m, tea.Batch(cmd, indexCmd)
|
||||||
|
|
||||||
|
case uisearch.SearchSelectMsg:
|
||||||
|
m.searchMode = false
|
||||||
|
if msg.IsFolder {
|
||||||
|
if m.tree != nil {
|
||||||
|
m.tree.SelectByPath(msg.Path)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
_, cmd := m.noteView.Update(note.LoadNoteMsg{Path: msg.Path})
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
|
case uisearch.SearchCancelMsg:
|
||||||
|
m.searchMode = false
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case fstree.NodeSelectedMsg:
|
case fstree.NodeSelectedMsg:
|
||||||
// Forward node selection to noteView
|
// Forward node selection to noteView
|
||||||
_, cmd := m.noteView.Update(note.LoadNoteMsg{Path: msg.Path})
|
_, cmd := m.noteView.Update(note.LoadNoteMsg{Path: msg.Path})
|
||||||
@@ -214,6 +241,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// search mode, forward all keys to search view
|
||||||
|
if m.searchMode {
|
||||||
|
_, cmd := m.searchView.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
// If editing, forward all keys to noteView and ignore global bindings
|
// If editing, forward all keys to noteView and ignore global bindings
|
||||||
if m.noteView.IsEditing() {
|
if m.noteView.IsEditing() {
|
||||||
_, cmd := m.noteView.Update(msg)
|
_, cmd := m.noteView.Update(msg)
|
||||||
@@ -223,6 +256,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
case "ctrl+f", "ctrl+p":
|
||||||
|
m.searchMode = true
|
||||||
|
_, cmd := m.searchView.Update(tea.WindowSizeMsg{
|
||||||
|
Width: m.terminalWidth,
|
||||||
|
Height: m.terminalHeight,
|
||||||
|
})
|
||||||
|
activateCmd := m.searchView.Activate()
|
||||||
|
return m, tea.Batch(cmd, activateCmd)
|
||||||
case "ctrl+b":
|
case "ctrl+b":
|
||||||
m.showSidebar = !m.showSidebar
|
m.showSidebar = !m.showSidebar
|
||||||
m.layout(m.terminalWidth, m.terminalHeight)
|
m.layout(m.terminalWidth, m.terminalHeight)
|
||||||
@@ -310,6 +351,13 @@ func (m model) View() string {
|
|||||||
return "Loading files..."
|
return "Loading files..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search mode takes over the entire screen
|
||||||
|
// bubbletea has no good "overaly stuff"
|
||||||
|
// I made a compositor but let's not use it for now
|
||||||
|
if m.searchMode {
|
||||||
|
return m.searchView.View()
|
||||||
|
}
|
||||||
|
|
||||||
tree := m.tree.View()
|
tree := m.tree.View()
|
||||||
tree = lipgloss.NewStyle().
|
tree = lipgloss.NewStyle().
|
||||||
Height(m.contentHeight).
|
Height(m.contentHeight).
|
||||||
|
|||||||
+6
-3
@@ -14,13 +14,16 @@ var (
|
|||||||
Primary = lipgloss.Color("#7D56F4")
|
Primary = lipgloss.Color("#7D56F4")
|
||||||
Highlight = lipgloss.Color("#89DDFF")
|
Highlight = lipgloss.Color("#89DDFF")
|
||||||
HoverHighlight = lipgloss.Color("#91B4D5")
|
HoverHighlight = lipgloss.Color("#91B4D5")
|
||||||
// TODO: to extend as needed, right now this is just a
|
FolderBlue = lipgloss.Color("#5FAFFF") // Blue for folder names in search
|
||||||
// placeholder
|
FileGreen = lipgloss.Color("#98C379") // Green for file icons
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== icons ====================
|
// ==================== icons (nerd fonts) ====================
|
||||||
var (
|
var (
|
||||||
VerticalLine = "│"
|
VerticalLine = "│"
|
||||||
ArrowDownIcon = "⌄"
|
ArrowDownIcon = "⌄"
|
||||||
ArrowRightIcon = "›"
|
ArrowRightIcon = "›"
|
||||||
|
// need nerd fonts to render correctly, how I got them? https://fontawesome.com/v4/icon/folder has a unicode
|
||||||
|
FolderIcon = "\uf07b"
|
||||||
|
FileIcon = "\uf0f6"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user