mirror of
https://github.com/wagoodman/dive
synced 2026-03-14 14:25:50 +01:00
feat: улучшить производительность и кэширование видимых узлов в панели файлового дерева
This commit is contained in:
parent
d1b1ec85f3
commit
f516b483c5
5 changed files with 159 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue