From f516b483c56483c443d5c63ce79afd7cfe84c703 Mon Sep 17 00:00:00 2001 From: Aslan Dukaev Date: Tue, 13 Jan 2026 13:28:24 +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=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D1=8B=D1=85=20?= =?UTF-8?q?=D1=83=D0=B7=D0=BB=D0=BE=D0=B2=20=D0=B2=20=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/internal/ui/v2/app/layout/engine.go | 11 +- cmd/dive/cli/internal/ui/v2/app/model.go | 9 +- .../ui/v2/panes/filetree/input_handler.go | 31 ++++- .../v2/panes/filetree/metadata_formatter.go | 23 ++- .../cli/internal/ui/v2/panes/filetree/pane.go | 131 ++++++++++++++---- 5 files changed, 159 insertions(+), 46 deletions(-) diff --git a/cmd/dive/cli/internal/ui/v2/app/layout/engine.go b/cmd/dive/cli/internal/ui/v2/app/layout/engine.go index c396efc..461de15 100644 --- a/cmd/dive/cli/internal/ui/v2/app/layout/engine.go +++ b/cmd/dive/cli/internal/ui/v2/app/layout/engine.go @@ -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 diff --git a/cmd/dive/cli/internal/ui/v2/app/model.go b/cmd/dive/cli/internal/ui/v2/app/model.go index c6a526f..aa540fc 100644 --- a/cmd/dive/cli/internal/ui/v2/app/model.go +++ b/cmd/dive/cli/internal/ui/v2/app/model.go @@ -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 diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go index 5d0a144..c7262e3 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go @@ -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 } diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/metadata_formatter.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/metadata_formatter.go index 165de23..26e2101 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/metadata_formatter.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/metadata_formatter.go @@ -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 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 be0111f..c1550af 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go @@ -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 {