diff --git a/internal/search/engine.go b/internal/search/engine.go new file mode 100644 index 0000000..04f1af0 --- /dev/null +++ b/internal/search/engine.go @@ -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 +} diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go new file mode 100644 index 0000000..757eb2d --- /dev/null +++ b/internal/search/engine_test.go @@ -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") + } +} diff --git a/internal/ui/fstree/model.go b/internal/ui/fstree/model.go index 281ac37..fc9b420 100644 --- a/internal/ui/fstree/model.go +++ b/internal/ui/fstree/model.go @@ -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 diff --git a/internal/ui/search/model.go b/internal/ui/search/model.go new file mode 100644 index 0000000..3ea4731 --- /dev/null +++ b/internal/ui/search/model.go @@ -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()) +} diff --git a/main.go b/main.go index ac9ba04..eac2bda 100644 --- a/main.go +++ b/main.go @@ -5,8 +5,10 @@ import ( "os" "os/exec" + "mend/internal/search" "mend/internal/ui/fstree" "mend/internal/ui/note" + uisearch "mend/internal/ui/search" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -37,6 +39,10 @@ type model struct { textInput textinput.Model inputMode bool pendingAction fstree.FsActionType + // search + searchEngine *search.SearchEngine + searchView *uisearch.SearchView + searchMode bool } func NewModel(rootPath string) *model { @@ -44,6 +50,8 @@ func NewModel(rootPath string) *model { ti.CharLimit = 156 ti.Width = 30 + searchEngine := search.NewSearchEngine() + return &model{ rootPath: rootPath, loading: true, @@ -51,6 +59,8 @@ func NewModel(rootPath string) *model { showStatusBar: false, showSidebar: true, 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, 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 + case uisearch.SearchCancelMsg: + m.searchMode = false + return m, nil + case fstree.NodeSelectedMsg: // Forward node selection to noteView _, 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 } + // 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 m.noteView.IsEditing() { _, cmd := m.noteView.Update(msg) @@ -223,6 +256,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": 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": m.showSidebar = !m.showSidebar m.layout(m.terminalWidth, m.terminalHeight) @@ -310,6 +351,13 @@ func (m model) View() string { 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 = lipgloss.NewStyle(). Height(m.contentHeight). diff --git a/styles/styles.go b/styles/styles.go index 590190a..30dd0de 100644 --- a/styles/styles.go +++ b/styles/styles.go @@ -14,13 +14,16 @@ var ( Primary = lipgloss.Color("#7D56F4") Highlight = lipgloss.Color("#89DDFF") HoverHighlight = lipgloss.Color("#91B4D5") - // TODO: to extend as needed, right now this is just a - // placeholder + FolderBlue = lipgloss.Color("#5FAFFF") // Blue for folder names in search + FileGreen = lipgloss.Color("#98C379") // Green for file icons ) -// ==================== icons ==================== +// ==================== icons (nerd fonts) ==================== var ( VerticalLine = "│" ArrowDownIcon = "⌄" ArrowRightIcon = "›" + // need nerd fonts to render correctly, how I got them? https://fontawesome.com/v4/icon/folder has a unicode + FolderIcon = "\uf07b" + FileIcon = "\uf0f6" )