feat: улучшить поиск с помощью регулярных выражений и добавить подсветку совпадений

This commit is contained in:
Aslan Dukaev 2026-01-16 19:03:55 +03:00
commit c0e00d0515
4 changed files with 176 additions and 39 deletions

View file

@ -98,6 +98,7 @@ type Model struct {
// Search state
searching bool // Whether search mode is active
previousPane Pane // Pane that was active before search (for Esc to restore focus)
searchInput textinput.Model // Search input field
filterRegex *regexp.Regexp // Compiled regex for tree filtering
currentMatch int // Index of currently selected match (-1 if no match)
@ -301,6 +302,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+f", "/":
// Enter search mode
// Save current pane so Esc can restore focus later
m.previousPane = m.activePane
m.searching = true
m.searchInput.Focus()
m.searchInput.SetValue("")
@ -611,6 +614,10 @@ func (m *Model) applyFilter(pattern string) {
newPane, _ = m.panes[PaneTree].Update(filetreepane.SetFlatModeMsg{Flat: flatMode})
m.panes[PaneTree] = newPane
// Send filter regex to tree pane for clean search results
newPane, _ = m.panes[PaneTree].Update(filetreepane.SetFilterRegexMsg{Regex: filterRegex})
m.panes[PaneTree] = newPane
// Count matches efficiently - get visible node count from pane
// Avoid double tree traversal
m.totalMatches = m.getVisibleNodeCount()
@ -699,20 +706,13 @@ func (m Model) updateSearch(msg tea.Msg) (tea.Model, tea.Cmd) {
m.panes[PaneTree] = newPane
return m, nil
case "n":
// Jump to next match
if m.totalMatches > 0 {
nextMatch := m.currentMatch + 1
if nextMatch >= m.totalMatches {
nextMatch = 0 // Wrap around
}
m.jumpToMatch(nextMatch)
}
return m, nil
// REMOVED: case "n" - was blocking input of letter 'n' (e.g., "nginx", "kernel")
// In clean search mode, "next match" is just "down" since non-matching files are hidden
case "up", "k", "down", "j":
case "up", "down":
// PASSTHROUGH: Forward navigation keys to tree pane
// This allows navigating the filtered tree while still in search mode
// REMOVED: "j", "k" - they were blocking input (e.g., "json")
if treePane, ok := m.panes[PaneTree]; ok {
updatedPane, cmd := treePane.Update(msg)
m.panes[PaneTree] = updatedPane
@ -721,9 +721,9 @@ func (m Model) updateSearch(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
case "ctrl+n":
// Alternative down key (Vim-style)
// Alternative down key (Standard CLI navigation)
if treePane, ok := m.panes[PaneTree]; ok {
downMsg := tea.KeyMsg{Type: tea.KeyDown, Runes: []rune{'j'}}
downMsg := tea.KeyMsg{Type: tea.KeyDown}
updatedPane, cmd := treePane.Update(downMsg)
m.panes[PaneTree] = updatedPane
cmds = append(cmds, cmd)
@ -731,9 +731,9 @@ func (m Model) updateSearch(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
case "ctrl+p":
// Alternative up key (Vim-style)
// Alternative up key (Standard CLI navigation)
if treePane, ok := m.panes[PaneTree]; ok {
upMsg := tea.KeyMsg{Type: tea.KeyUp, Runes: []rune{'k'}}
upMsg := tea.KeyMsg{Type: tea.KeyUp}
updatedPane, cmd := treePane.Update(upMsg)
m.panes[PaneTree] = updatedPane
cmds = append(cmds, cmd)

View file

@ -1,6 +1,7 @@
package filetree
import (
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
@ -15,14 +16,15 @@ func RenderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, prefix s
if node == nil {
return
}
row := RenderRow(node, prefix, "", isSelected, width)
row := RenderRow(node, prefix, "", isSelected, width, nil)
sb.WriteString(row)
sb.WriteString("\n")
}
// RenderRow renders a single tree node row using lipgloss.JoinHorizontal for clean layout
// displayName is optional - if empty, node.Name will be used
func RenderRow(node *filetree.FileNode, prefix string, displayName string, isSelected bool, width int) string {
// filterRegex is optional - if provided, matching text will be highlighted
func RenderRow(node *filetree.FileNode, prefix string, displayName string, isSelected bool, width int, filterRegex *regexp.Regexp) string {
// 1. Icon and color
icon := styles.IconFile
color := styles.DiffNormalColor
@ -75,9 +77,10 @@ func RenderRow(node *filetree.FileNode, prefix string, displayName string, isSel
}
// 4. Common background for selected state
// IMPROVED: Better contrast with lighter background
bg := lipgloss.Color("")
if isSelected {
bg = lipgloss.Color("#1C1C1E")
bg = lipgloss.Color("#48484A") // Lighter gray for better contrast
}
// 5. Build styled components
@ -90,9 +93,18 @@ func RenderRow(node *filetree.FileNode, prefix string, displayName string, isSel
styledIcon := iconStyle.Render(icon)
// Filename with diff color
nameStyle := lipgloss.NewStyle().Foreground(color).Background(bg)
if isSelected {
nameStyle = nameStyle.Bold(true).Foreground(styles.PrimaryColor)
// Apply match highlighting if filter is provided
var finalStyledName string
if filterRegex != nil && filterRegex.MatchString(name) {
// Highlight matching portions
finalStyledName = highlightMatches(name, filterRegex, color, isSelected, bg)
} else {
// No highlighting, use normal style
nameStyle := lipgloss.NewStyle().Foreground(color).Background(bg)
if isSelected {
nameStyle = nameStyle.Bold(true).Foreground(styles.PrimaryColor)
}
finalStyledName = nameStyle.Render(name)
}
// 6. Render metadata cells (fixed width)
@ -136,15 +148,27 @@ func RenderRow(node *filetree.FileNode, prefix string, displayName string, isSel
availableForName = 5
}
// Truncate name if needed
// Truncate name if needed (check visual width, not character count)
truncatedName := name
if runewidth.StringWidth(name) > availableForName {
truncatedName = runewidth.Truncate(name, availableForName, "…")
if runewidth.StringWidth(finalStyledName) > availableForName {
// If highlighting makes it too long, truncate without highlighting
if runewidth.StringWidth(name) > availableForName {
truncatedName = runewidth.Truncate(name, availableForName, "…")
}
// Re-apply highlighting to truncated name
if filterRegex != nil && filterRegex.MatchString(truncatedName) {
finalStyledName = highlightMatches(truncatedName, filterRegex, color, isSelected, bg)
} else {
nameStyle := lipgloss.NewStyle().Foreground(color).Background(bg)
if isSelected {
nameStyle = nameStyle.Bold(true).Foreground(styles.PrimaryColor)
}
finalStyledName = nameStyle.Render(truncatedName)
}
}
styledName := nameStyle.Render(truncatedName)
// 8. Calculate flexible padding to push metadata to right edge
contentWidth := fixedPartWidth + lipgloss.Width(styledName) + metaBlockWidth
contentWidth := fixedPartWidth + lipgloss.Width(finalStyledName) + metaBlockWidth
paddingNeeded := width - contentWidth
if paddingNeeded < 1 {
paddingNeeded = 1
@ -157,20 +181,73 @@ func RenderRow(node *filetree.FileNode, prefix string, displayName string, isSel
lipgloss.Top,
styledPrefix,
styledIcon,
styledName,
finalStyledName,
padding,
metaBlock,
)
}
// highlightMatches applies regex highlighting to matching portions of the text
func highlightMatches(text string, filter *regexp.Regexp, baseColor lipgloss.Color, isSelected bool, bg lipgloss.Color) string {
// Find all matches
matches := filter.FindAllStringIndex(text, -1)
if len(matches) == 0 {
// Should not happen since we check MatchString before calling
nameStyle := lipgloss.NewStyle().Foreground(baseColor).Background(bg)
if isSelected {
nameStyle = nameStyle.Bold(true).Foreground(styles.PrimaryColor)
}
return nameStyle.Render(text)
}
// Build highlighted string
var result strings.Builder
lastEnd := 0
// Highlight color: bright yellow for visibility
highlightColor := lipgloss.Color("#FFFF00") // Bright yellow
if isSelected {
highlightColor = lipgloss.Color("#FFD700") // Gold for selected state
}
normalStyle := lipgloss.NewStyle().Foreground(baseColor).Background(bg)
if isSelected {
normalStyle = normalStyle.Bold(true).Foreground(styles.PrimaryColor)
}
highlightStyle := lipgloss.NewStyle().Foreground(highlightColor).Background(bg)
if isSelected {
highlightStyle = highlightStyle.Bold(true)
}
for _, match := range matches {
// Add non-matching text before this match
if match[0] > lastEnd {
result.WriteString(normalStyle.Render(text[lastEnd:match[0]]))
}
// Add matching text with highlight
result.WriteString(highlightStyle.Render(text[match[0]:match[1]]))
lastEnd = match[1]
}
// Add remaining text after last match
if lastEnd < len(text) {
result.WriteString(normalStyle.Render(text[lastEnd:]))
}
return result.String()
}
// RenderNodeLine renders a single node line for viewport.
// This is a convenience wrapper around RenderRow.
func RenderNodeLine(node *filetree.FileNode, prefix string, isSelected bool, width int) string {
return RenderRow(node, prefix, "", isSelected, width)
func RenderNodeLine(node *filetree.FileNode, prefix string, isSelected bool, width int, filterRegex *regexp.Regexp) string {
return RenderRow(node, prefix, "", isSelected, width, filterRegex)
}
// RenderNodeLineWithDisplayName renders a single node line with a custom display name.
// This is used for flat view where the full path is shown instead of just the name.
func RenderNodeLineWithDisplayName(node *filetree.FileNode, prefix string, displayName string, isSelected bool, width int) string {
return RenderRow(node, prefix, displayName, isSelected, width)
func RenderNodeLineWithDisplayName(node *filetree.FileNode, prefix string, displayName string, isSelected bool, width int, filterRegex *regexp.Regexp) string {
return RenderRow(node, prefix, displayName, isSelected, width, filterRegex)
}

View file

@ -1,6 +1,7 @@
package filetree
import (
"regexp"
"strings"
tea "github.com/charmbracelet/bubbletea"
@ -49,6 +50,11 @@ type SetCursorMsg struct {
Index int
}
// SetFilterRegexMsg is sent to update the filter regex for search/hide logic
type SetFilterRegexMsg struct {
Regex *regexp.Regexp
}
// Pane manages the file tree using viewport for smooth scrolling
type Pane struct {
focused bool
@ -60,10 +66,11 @@ type Pane struct {
viewport viewport.Model
// Data
nodes []VisibleNode
cursor int // Current selected index in nodes
scrollOff int // Number of lines to keep visible above/below cursor (scrolloff)
flatMode bool // true = Flat View, false = Tree View
nodes []VisibleNode
cursor int // Current selected index in nodes
scrollOff int // Number of lines to keep visible above/below cursor (scrolloff)
flatMode bool // true = Flat View, false = Tree View
filterRegex *regexp.Regexp // Active filter regex for highlighting and clean search
}
// New creates a new tree pane with viewport for smooth scrolling
@ -245,6 +252,14 @@ func (p *Pane) Update(msg tea.Msg) (common.Pane, tea.Cmd) {
// Set cursor to specific position
p.SetTreeIndex(msg.Index)
return p, nil
case SetFilterRegexMsg:
// Update filter regex and rebuild nodes with clean search logic
if p.filterRegex != msg.Regex {
p.filterRegex = msg.Regex
p.rebuildNodes()
}
return p, nil
}
// Update viewport
@ -371,9 +386,9 @@ func (p Pane) renderVisibleContent() string {
// renderNodeLine renders a single node line
func (p Pane) renderNodeLine(node VisibleNode, isSelected bool) string {
if node.DisplayName != "" {
return RenderNodeLineWithDisplayName(node.Node, node.Prefix, node.DisplayName, isSelected, p.width-2)
return RenderNodeLineWithDisplayName(node.Node, node.Prefix, node.DisplayName, isSelected, p.width-2, p.filterRegex)
}
return RenderNodeLine(node.Node, node.Prefix, isSelected, p.width-2)
return RenderNodeLine(node.Node, node.Prefix, isSelected, p.width-2, p.filterRegex)
}
// rebuildNodes rebuilds the visible nodes list when tree structure changes
@ -385,10 +400,16 @@ func (p *Pane) rebuildNodes() {
}
// Flatten tree structure into visible nodes
// Use different strategy based on current view mode
if p.flatMode {
// Use different strategy based on current view mode and filter state
if p.flatMode && p.filterRegex != nil {
// Clean search mode: Flat View + Active Filter
// Show ONLY matching nodes, no parent directories (Google-style)
p.nodes = CollectSearchResults(p.treeVM.ViewTree.Root, p.filterRegex)
} else if p.flatMode {
// Flat View without filter (manual toggle with 'f')
p.nodes = CollectFlatNodes(p.treeVM.ViewTree.Root)
} else {
// Regular Tree View
p.nodes = CollectVisibleNodes(p.treeVM.ViewTree.Root)
}

View file

@ -1,6 +1,7 @@
package filetree
import (
"regexp"
"sort"
"strings"
@ -115,6 +116,44 @@ func CollectFlatNodes(root *filetree.FileNode) []VisibleNode {
return nodes
}
// CollectSearchResults collects ONLY nodes that match the filter regex
// This implements "Google-style" clean search: no parent directories shown
// unless they themselves match the search pattern
func CollectSearchResults(root *filetree.FileNode, filter *regexp.Regexp) []VisibleNode {
var nodes []VisibleNode
var traverse func(*filetree.FileNode)
traverse = func(node *filetree.FileNode) {
if node == nil {
return
}
// Skip root node (it has no parent and represents the filesystem root)
if node.Parent != nil {
// Check if this node matches the filter
// We use the full path for matching (e.g., "/etc/fstab")
if filter.MatchString(node.Path()) {
nodes = append(nodes, VisibleNode{
Node: node,
Prefix: "", // No tree prefix in flat mode
DisplayName: node.Path(), // Show full path
})
}
}
// IMPORTANT: Always recurse into children, even if current node doesn't match
// This allows finding matches deep in the directory tree
// Use ModelTree (not ViewTree) to search ALL files, not just visible ones
sortedChildren := SortChildren(node.Children)
for _, child := range sortedChildren {
traverse(child)
}
}
traverse(root)
return nodes
}
// SortChildren sorts node children: directories first, then files, all alphabetically
func SortChildren(children map[string]*filetree.FileNode) []*filetree.FileNode {
if children == nil {