mirror of
https://github.com/wagoodman/dive
synced 2026-03-14 14:25:50 +01:00
feat: улучшить поиск с помощью регулярных выражений и добавить подсветку совпадений
This commit is contained in:
parent
f25f68ce57
commit
c0e00d0515
4 changed files with 176 additions and 39 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue