From c0e00d05153c2c9422ffd7213667e50ce0e64314 Mon Sep 17 00:00:00 2001 From: Aslan Dukaev Date: Fri, 16 Jan 2026 19:03:55 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=D0=BE=D1=89=D1=8C=D1=8E=20=D1=80=D0=B5=D0=B3=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=D1=80=D0=BD=D1=8B=D1=85=20=D0=B2=D1=8B=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D1=83=20=D1=81=D0=BE=D0=B2=D0=BF=D0=B0=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/dive/cli/internal/ui/v2/app/model.go | 30 ++--- .../ui/v2/panes/filetree/node_renderer.go | 109 +++++++++++++++--- .../cli/internal/ui/v2/panes/filetree/pane.go | 37 ++++-- .../ui/v2/panes/filetree/tree_traversal.go | 39 +++++++ 4 files changed, 176 insertions(+), 39 deletions(-) diff --git a/cmd/dive/cli/internal/ui/v2/app/model.go b/cmd/dive/cli/internal/ui/v2/app/model.go index d89778b..81020fb 100644 --- a/cmd/dive/cli/internal/ui/v2/app/model.go +++ b/cmd/dive/cli/internal/ui/v2/app/model.go @@ -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) diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go index af0553d..f0f29be 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go @@ -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) } diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go index 0ca5037..95392a4 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go @@ -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) } diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/tree_traversal.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/tree_traversal.go index 37ba22f..ab769ab 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/tree_traversal.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/tree_traversal.go @@ -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 {