feat: улучшить производительность и кэширование видимых узлов в панели файлового дерева

This commit is contained in:
Aslan Dukaev 2026-01-13 13:28:24 +03:00
commit f516b483c5
5 changed files with 159 additions and 46 deletions

View file

@ -2,10 +2,13 @@ package layout
// Layout constants for viewport calculations
const (
BorderHeight = 2 // Top + Bottom border lines
HeaderHeight = 2 // Title line + newline/padding separator
BorderHeight = 2 // Top + Bottom border lines
HeaderHeight = 2 // Title line + newline/padding separator
BoxContentPadding = BorderHeight + HeaderHeight // Total padding inside RenderBox
ContentVisualOffset = 3 // Offset for mouse hit testing: 1 border + 1 title + 1 padding
ContentVisualOffset = 3 // Offset for mouse hit testing: 1 border + 1 title + 1 padding
// Additional header heights for specific panes
TreeTableHeaderHeight = 3 // "Name Size Permissions" table header
)
// Result stores calculated pane dimensions
@ -54,7 +57,7 @@ func (e *Engine) Calculate(width, height int) Result {
// Calculate heights (left column)
// Layers: flexible, Image: flexible, Details: at least 12 lines
result.DetailsHeight = 12 // Minimum for command display
result.DetailsHeight = 12 // Minimum for command display
if result.DetailsHeight > availableHeight/3 {
result.DetailsHeight = availableHeight / 3

View file

@ -286,8 +286,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.treePane.Blur()
case filetreepane.NodeToggledMsg:
// Tree node was toggled - tree pane already updated its content
// Nothing to do here
// Forward message to tree pane to refresh its visibleNodes cache
// CRITICAL: This fixes the copy-on-write issue. The InputHandler's callback
// modified the collapsed flag in the tree data, but the visible copy of
// treePane (stored in this Model) needs to refresh its cache to show changes.
newPane, cmd := m.treePane.Update(msg)
m.treePane = newPane.(filetreepane.Pane)
cmds = append(cmds, cmd)
case filetreepane.RefreshTreeContentMsg:
// Request to refresh tree content

View file

@ -8,13 +8,14 @@ import (
// InputHandler handles keyboard and mouse input events
type InputHandler struct {
navigation *Navigation
selection *Selection
viewportMgr *ViewportManager
treeVM *viewmodel.FileTreeViewModel
focused bool
width int
height int
navigation *Navigation
selection *Selection
viewportMgr *ViewportManager
treeVM *viewmodel.FileTreeViewModel
toggleCollapseFn func() tea.Cmd // Callback for toggle collapse operation
focused bool
width int
height int
}
// NewInputHandler creates a new input handler
@ -41,6 +42,16 @@ func (h *InputHandler) SetSize(width, height int) {
h.height = height
}
// SetToggleCollapseFunc sets the callback function for toggle collapse operation
func (h *InputHandler) SetToggleCollapseFunc(fn func() tea.Cmd) {
h.toggleCollapseFn = fn
}
// SetTreeVM updates the tree viewmodel reference
func (h *InputHandler) SetTreeVM(treeVM *viewmodel.FileTreeViewModel) {
h.treeVM = treeVM
}
// HandleKeyPress processes keyboard input
// Returns (commands, consumed) - if consumed is true, event should not propagate to viewport
func (h *InputHandler) HandleKeyPress(msg tea.KeyMsg) (cmds []tea.Cmd, consumed bool) {
@ -129,6 +140,12 @@ func (h *InputHandler) HandleMouseClick(msg tea.MouseMsg) tea.Cmd {
// toggleCollapse toggles the current node's collapse state
func (h *InputHandler) toggleCollapse() tea.Cmd {
// Use callback if available (delegates to Pane.toggleCollapse with cached nodes)
if h.toggleCollapseFn != nil {
return h.toggleCollapseFn()
}
// Fallback to old implementation if callback not set
if h.treeVM == nil || h.treeVM.ViewTree == nil {
return nil
}

View file

@ -1,5 +1,10 @@
package filetree
import (
"os"
"strings"
)
// Column width constants for metadata display
const (
PermWidth = 11 // "-rwxr-xr-x"
@ -10,17 +15,27 @@ const (
// FormatPermissions converts os.FileMode to Unix permission string (e.g. "-rwxr-xr-x")
func FormatPermissions(mode interface{}) string {
var m uint32
switch v := mode.(type) {
case os.FileMode:
// Use Go's built-in String() method for os.FileMode
// It produces strings like "-rwxr-xr-x", "drwxr-xr-x", "lrwxrwxrwx"
str := v.String()
// Ensure fixed width of 10 characters
if len(str) < 10 {
return str + strings.Repeat(" ", 10-len(str))
}
return str
case uint32:
m = v
return formatRawMode(v)
case int:
m = uint32(v)
return formatRawMode(uint32(v))
default:
return "----------"
}
}
// Convert to string representation
// formatRawMode formats a raw uint32 mode value (for backward compatibility)
func formatRawMode(m uint32) string {
perms := []rune("----------")
// File type

View file

@ -3,8 +3,8 @@ package filetree
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout"
@ -28,15 +28,18 @@ type RefreshTreeContentMsg struct {
// Pane manages the file tree
type Pane struct {
focused bool
width int
height int
treeVM *viewmodel.FileTreeViewModel
focused bool
width int
height int
treeVM *viewmodel.FileTreeViewModel
// Cache of currently visible nodes to avoid re-traversal every frame
visibleNodes []VisibleNode
// Components
selection *Selection
viewportMgr *ViewportManager
navigation *Navigation
selection *Selection
viewportMgr *ViewportManager
navigation *Navigation
inputHandler *InputHandler
}
@ -57,10 +60,15 @@ func New(treeVM *viewmodel.FileTreeViewModel) Pane {
focused: false,
width: 80,
height: 20,
visibleNodes: []VisibleNode{},
}
// Set up callbacks
p.navigation.SetVisibleNodesFunc(func() []VisibleNode {
// Return the cached nodes if available to speed up navigation checks
if p.visibleNodes != nil {
return p.visibleNodes
}
if p.treeVM == nil || p.treeVM.ViewTree == nil {
return nil
}
@ -70,6 +78,10 @@ func New(treeVM *viewmodel.FileTreeViewModel) Pane {
p.navigation.SetRefreshFunc(p.updateContent)
p.navigation.SetToggleCollapseFunc(p.toggleCollapse)
// Set up callback for InputHandler to use Pane's toggleCollapse implementation
// This ensures it uses the cached visibleNodes instead of re-traversing the tree
p.inputHandler.SetToggleCollapseFunc(p.toggleCollapse)
// IMPORTANT: Generate content immediately so viewport is not empty on startup
p.updateContent()
return p
@ -82,7 +94,11 @@ func (m *Pane) SetSize(width, height int) {
m.inputHandler.SetSize(width, height)
viewportWidth := width - 2
viewportHeight := height - layout.BoxContentPadding
// Calculate viewport height accounting for:
// - BoxContentPadding: borders (2) + box header (2) = 4
// - TreeTableHeaderHeight: "Name Size Permissions" header (1)
viewportHeight := height - layout.BoxContentPadding - layout.TreeTableHeaderHeight
if viewportHeight < 0 {
viewportHeight = 0
}
@ -97,6 +113,11 @@ func (m *Pane) SetSize(width, height int) {
// SetTreeVM updates the tree viewmodel
func (m *Pane) SetTreeVM(treeVM *viewmodel.FileTreeViewModel) {
m.treeVM = treeVM
// CRITICAL: Also update the reference in InputHandler to prevent desync
// Without this, InputHandler would continue operating on the old tree reference
m.inputHandler.SetTreeVM(treeVM)
m.selection.SetTreeIndex(0)
m.viewportMgr.GotoTop()
m.updateContent()
@ -169,6 +190,13 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case NodeToggledMsg:
// A folder was collapsed/expanded, need to refresh visibleNodes cache
// CRITICAL: This fixes the copy-on-write issue where InputHandler updates
// the old copy of the Pane. By handling this message in the active Pane's
// Update method, we ensure the visible copy of the Pane refreshes its cache.
m.updateContent()
case RefreshTreeContentMsg:
m.updateContent()
}
@ -185,8 +213,10 @@ func (m Pane) View() string {
// 1. Generate static header
header := RenderHeader(m.width)
// 2. Get viewport content
content := m.viewportMgr.GetViewport().View()
// 2. Render ONLY the visible rows based on viewport state (Virtualization)
// We do NOT use m.viewportMgr.GetViewport().View() for the content because
// we only want to render the lines that are currently on screen to avoid lag.
content := m.renderVisibleContent()
// 3. Combine: Header + Content
fullContent := lipgloss.JoinVertical(lipgloss.Left, header, content)
@ -194,35 +224,73 @@ func (m Pane) View() string {
return styles.RenderBox("Current Layer Contents", m.width, m.height, fullContent, m.focused)
}
// updateContent regenerates the viewport content
// updateContent refreshes the cache and updates the viewport scroll bounds
func (m *Pane) updateContent() {
if m.treeVM == nil {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
m.visibleNodes = nil
m.viewportMgr.SetContent("No tree data")
return
}
content := m.renderTreeContent()
if content == "" {
content = "(File tree rendering in progress...)"
// 1. Cache the visible nodes structure (fast pointer traversal)
m.visibleNodes = CollectVisibleNodes(m.treeVM.ViewTree.Root)
// 2. Set "dummy" content to the viewport to establish correct scrollbar math
// We don't render the text here. We just give the viewport a string with
// the correct number of newlines so it knows how tall the content *would* be.
// This makes PageDown/Up and scrolling work correctly.
count := len(m.visibleNodes)
if count > 0 {
dummyContent := strings.Repeat("\n", count-1)
m.viewportMgr.SetContent(dummyContent)
} else {
m.viewportMgr.SetContent("")
}
m.viewportMgr.SetContent(content)
}
// renderTreeContent generates the tree content
func (m *Pane) renderTreeContent() string {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
return "No tree data"
// renderVisibleContent generates strings only for the rows currently visible in the viewport
func (m *Pane) renderVisibleContent() string {
if len(m.visibleNodes) == 0 {
return "No files"
}
// Get current scroll window
yOffset := m.viewportMgr.GetYOffset()
height := m.viewportMgr.GetHeight()
// Calculate slice bounds
start := yOffset
end := start + height
// Clamp bounds
if start < 0 {
start = 0
}
if start > len(m.visibleNodes) {
start = len(m.visibleNodes)
}
if end > len(m.visibleNodes) {
end = len(m.visibleNodes)
}
// Render loop - only for visible items (e.g., 20 items instead of 10,000)
var sb strings.Builder
visibleNodes := CollectVisibleNodes(m.treeVM.ViewTree.Root)
viewportWidth := m.viewportMgr.GetViewport().Width
for i, vn := range visibleNodes {
for i := start; i < end; i++ {
vn := m.visibleNodes[i]
isSelected := (i == m.selection.GetTreeIndex())
RenderNodeWithCursor(&sb, vn.Node, vn.Prefix, isSelected, viewportWidth)
}
// If the rendered content is shorter than the viewport (e.g. end of list),
// pad with empty lines to maintain box size
renderedLines := end - start
if renderedLines < height {
// padding := height - renderedLines
// sb.WriteString(strings.Repeat("\n", padding))
}
return sb.String()
}
@ -232,11 +300,16 @@ func (m *Pane) toggleCollapse() tea.Cmd {
return nil
}
visibleNodes := CollectVisibleNodes(m.treeVM.ViewTree.Root)
// Use cached nodes for index lookup
if len(m.visibleNodes) == 0 {
return nil
}
treeIndex := m.selection.GetTreeIndex()
if treeIndex >= len(visibleNodes) {
m.selection.MoveToIndex(len(visibleNodes) - 1)
// Bounds check
if treeIndex >= len(m.visibleNodes) {
m.selection.MoveToIndex(len(m.visibleNodes) - 1)
treeIndex = m.selection.GetTreeIndex()
}
if treeIndex < 0 {
@ -244,14 +317,14 @@ func (m *Pane) toggleCollapse() tea.Cmd {
treeIndex = m.selection.GetTreeIndex()
}
if treeIndex < len(visibleNodes) {
selectedNode := visibleNodes[treeIndex].Node
if treeIndex < len(m.visibleNodes) {
selectedNode := m.visibleNodes[treeIndex].Node
if selectedNode.Data.FileInfo.IsDir() {
// Toggle the collapsed flag directly on the node
selectedNode.Data.ViewInfo.Collapsed = !selectedNode.Data.ViewInfo.Collapsed
// Just refresh the UI - don't call treeVM.Update() as it rebuilds the tree
// Refresh content (re-collect nodes and update viewport bounds)
m.updateContent()
return func() tea.Msg {