Refactor filetree pane: Simplify architecture, remove selection and viewport manager components

- Consolidate navigation and selection logic into the Pane struct.
- Replace the Selection and ViewportManager components with direct implementations.
- Introduce FocusStateMsg for managing focus state from the parent.
- Optimize visible node handling by flattening the tree structure into visible rows.
- Update mouse handling to support local coordinates and improve interaction.
- Enhance performance by caching file statistics in the layers pane.
- Introduce a zone manager for handling clickable regions in the UI.
- Update styles to include a new HelpStyle for the instruction bar.
This commit is contained in:
Aslan Dukaev 2026-01-15 22:29:53 +03:00
commit 76b8f2034f
21 changed files with 898 additions and 1072 deletions

View file

@ -1,5 +1,6 @@
package app
// Note: LocalMouseMsg is now defined in common package to avoid import cycles
// Note: LayerChangedMsg is now defined in panes/layers package
// Note: NodeToggledMsg, TreeSelectionChangedMsg, RefreshTreeContentMsg are now defined in panes/filetree package

View file

@ -2,8 +2,6 @@ package app
import (
"context"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/help"
@ -17,6 +15,7 @@ import (
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/details"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/layers"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/common"
filetree "github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/dive/image"
)
@ -130,9 +129,9 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
h := help.New()
h.Width = 80
h.Styles.ShortKey = styles.StatusStyle
h.Styles.ShortDesc = styles.StatusStyle
h.Styles.Ellipsis = styles.StatusStyle
h.Styles.ShortKey = styles.HelpStyle
h.Styles.ShortDesc = styles.HelpStyle
h.Styles.Ellipsis = styles.HelpStyle
f := NewFilterModel()
layerDetailModal := NewLayerDetailModal()
@ -143,9 +142,6 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
imagePane := imagepane.New(&analysis)
treePane := filetreepane.New(treeVM)
// Set initial focus
layersPane.Focus()
// Create model with initial dimensions
model := Model{
analysis: analysis,
@ -168,13 +164,32 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
layerDetailModal: layerDetailModal,
}
// CRITICAL: Calculate initial layout and set pane sizes immediately
// CRITICAL: Calculate initial layout immediately
// This ensures panes have correct dimensions before first render
model.recalculateLayout()
model.layersPane.SetSize(model.layout.LeftWidth, model.layout.LayersHeight)
model.detailsPane.SetSize(model.layout.LeftWidth, model.layout.DetailsHeight)
model.imagePane.SetSize(model.layout.LeftWidth, model.layout.ImageHeight)
model.treePane.SetSize(model.layout.RightWidth, model.layout.TreeHeight)
// Send LayoutMsg to set initial pane sizes via message passing
layoutMsg := common.LayoutMsg{
LeftWidth: model.layout.LeftWidth,
LayersHeight: model.layout.LayersHeight,
DetailsHeight: model.layout.DetailsHeight,
ImageHeight: model.layout.ImageHeight,
RightWidth: model.layout.RightWidth,
TreeHeight: model.layout.TreeHeight,
}
// Update panes with initial layout
newLayers, _ := model.layersPane.Update(layoutMsg)
model.layersPane = newLayers.(layers.Pane)
newDetails, _ := model.detailsPane.Update(layoutMsg)
model.detailsPane = newDetails.(details.Pane)
newImage, _ := model.imagePane.Update(layoutMsg)
model.imagePane = newImage.(imagepane.Pane)
newTree, _ := model.treePane.Update(layoutMsg)
model.treePane = newTree.(filetreepane.Pane)
return model
}
@ -188,16 +203,22 @@ func (m Model) Init() tea.Cmd {
m.updateTreeForCurrentLayer()
}
// Initialize details pane with current layer
// Initialize details pane with current layer via message
if m.layerVM != nil && len(m.layerVM.Layers) > 0 {
layerIndex := m.layerVM.LayerIndex
if layerIndex >= 0 && layerIndex < len(m.layerVM.Layers) {
m.detailsPane.SetLayer(m.layerVM.Layers[layerIndex])
layerMsg := common.LayerSelectedMsg{
Layer: m.layerVM.Layers[layerIndex],
LayerIndex: layerIndex,
}
newDetails, _ := m.detailsPane.Update(layerMsg)
m.detailsPane = newDetails.(details.Pane)
}
}
// Note: Pane sizes are already set in NewModel with initial layout calculation
// Content is already generated in constructors (NewLayersPane, etc.)
// CRITICAL: Set initial focus state
// Parent tells children which pane is focused via FocusStateMsg
m.sendFocusStates()
return tea.Batch(cmds...)
}
@ -273,18 +294,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case layers.LayerChangedMsg:
// Layer changed - update details pane and tree
// Layer changed - update details pane and tree via messages
if m.layerVM != nil && msg.LayerIndex >= 0 && msg.LayerIndex < len(m.layerVM.Layers) {
m.detailsPane.SetLayer(m.layerVM.Layers[msg.LayerIndex])
layerMsg := common.LayerSelectedMsg{
Layer: m.layerVM.Layers[msg.LayerIndex],
LayerIndex: msg.LayerIndex,
}
newDetails, _ := m.detailsPane.Update(layerMsg)
m.detailsPane = newDetails.(details.Pane)
}
m.updateTreeForCurrentLayer()
// Update focus state
m.layersPane.Focus()
m.detailsPane.Blur()
m.imagePane.Blur()
m.treePane.Blur()
case filetreepane.NodeToggledMsg:
// Forward message to tree pane to refresh its visibleNodes cache
// CRITICAL: This fixes the copy-on-write issue. The InputHandler's callback
@ -303,7 +323,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.layerDetailModal.Show(msg.Layer)
case tea.MouseMsg:
// Route mouse events to appropriate pane
// Route mouse events to appropriate pane with coordinate transformation
// Parent handles ALL coordinate math - children receive simple local coordinates
x, y := msg.X, msg.Y
l := m.layout
@ -316,44 +337,61 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
detailsEndY := layersEndY + l.DetailsHeight
if y < layersEndY {
// Layers pane
newPane, cmd := m.layersPane.Update(msg)
// Layers pane - transform to local coordinates
// X: relative to pane border (will be adjusted by child for content area)
// Y: relative to content area (accounting for ContentVisualOffset)
localX := x
localY := y - l.ContentStartY
localMsg := common.LocalMouseMsg{
MouseMsg: msg,
LocalX: localX,
LocalY: localY,
}
newPane, cmd := m.layersPane.Update(localMsg)
m.layersPane = newPane.(layers.Pane)
cmds = append(cmds, cmd)
if m.activePane != PaneLayer {
m.activePane = PaneLayer
m.updateFocus()
m.sendFocusStates()
}
} else if y >= layersEndY && y < detailsEndY {
// Details pane (read-only, no mouse handling)
if m.activePane != PaneDetails {
m.activePane = PaneDetails
m.updateFocus()
m.sendFocusStates()
}
} else {
// Image pane
newPane, cmd := m.imagePane.Update(msg)
// Image pane - transform to local coordinates
localX := x
localY := y - detailsEndY
localMsg := common.LocalMouseMsg{
MouseMsg: msg,
LocalX: localX,
LocalY: localY,
}
newPane, cmd := m.imagePane.Update(localMsg)
m.imagePane = newPane.(imagepane.Pane)
cmds = append(cmds, cmd)
if m.activePane != PaneImage {
m.activePane = PaneImage
m.updateFocus()
m.sendFocusStates()
}
}
} else if inRightCol {
// Tree pane
// CRITICAL FIX: Adjust X coordinate to be relative to tree pane
// Mouse events come in absolute coordinates (from window start)
// Tree pane expects local coordinates (from pane start, i.e., LeftWidth)
localMsg := msg
localMsg.X -= l.LeftWidth
// Tree pane - transform to local coordinates
localX := x - l.LeftWidth
localY := y - l.ContentStartY
localMsg := common.LocalMouseMsg{
MouseMsg: msg,
LocalX: localX,
LocalY: localY,
}
newPane, cmd := m.treePane.Update(localMsg)
m.treePane = newPane.(filetreepane.Pane)
cmds = append(cmds, cmd)
if m.activePane != PaneTree {
m.activePane = PaneTree
m.updateFocus()
m.sendFocusStates()
}
}
@ -363,11 +401,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.help.Width = msg.Width
m.recalculateLayout()
// Update pane sizes
m.layersPane.SetSize(m.layout.LeftWidth, m.layout.LayersHeight)
m.detailsPane.SetSize(m.layout.LeftWidth, m.layout.DetailsHeight)
m.imagePane.SetSize(m.layout.LeftWidth, m.layout.ImageHeight)
m.treePane.SetSize(m.layout.RightWidth, m.layout.TreeHeight)
// Send LayoutMsg to all panes with their new dimensions
// This replaces direct SetSize() calls with message passing
layoutMsg := common.LayoutMsg{
LeftWidth: m.layout.LeftWidth,
LayersHeight: m.layout.LayersHeight,
DetailsHeight: m.layout.DetailsHeight,
ImageHeight: m.layout.ImageHeight,
RightWidth: m.layout.RightWidth,
TreeHeight: m.layout.TreeHeight,
}
// Broadcast to all panes - they will extract what they need
var layoutCmds []tea.Cmd
newLayers, cmd := m.layersPane.Update(layoutMsg)
m.layersPane = newLayers.(layers.Pane)
layoutCmds = append(layoutCmds, cmd)
newDetails, cmd := m.detailsPane.Update(layoutMsg)
m.detailsPane = newDetails.(details.Pane)
layoutCmds = append(layoutCmds, cmd)
newImage, cmd := m.imagePane.Update(layoutMsg)
m.imagePane = newImage.(imagepane.Pane)
layoutCmds = append(layoutCmds, cmd)
newTree, cmd := m.treePane.Update(layoutMsg)
m.treePane = newTree.(filetreepane.Pane)
layoutCmds = append(layoutCmds, cmd)
cmds = append(cmds, layoutCmds...)
}
// Update help
@ -385,27 +449,43 @@ func (m *Model) togglePane() {
m.activePane = PaneLayer
}
m.updateFocus()
m.sendFocusStates()
}
func (m *Model) updateFocus() {
// Update focus state based on current active pane
// Blur all panes first
m.layersPane.Blur()
m.detailsPane.Blur()
m.imagePane.Blur()
m.treePane.Blur()
// Focus only the active pane
func (m *Model) sendFocusStates() {
// Send FocusStateMsg to all panes based on current active pane
// Parent is the Single Source of Truth - children receive focus state via messages
switch m.activePane {
case PaneLayer:
m.layersPane.Focus()
newPane, _ := m.layersPane.Update(layers.FocusStateMsg{Focused: true})
m.layersPane = newPane.(layers.Pane)
case PaneDetails:
m.detailsPane.Focus() // Show focus visually
newPane, _ := m.detailsPane.Update(details.FocusStateMsg{Focused: true})
m.detailsPane = newPane.(details.Pane)
case PaneImage:
m.imagePane.Focus()
newPane, _ := m.imagePane.Update(imagepane.FocusStateMsg{Focused: true})
m.imagePane = newPane.(imagepane.Pane)
case PaneTree:
m.treePane.Focus()
newPane, _ := m.treePane.Update(filetreepane.FocusStateMsg{Focused: true})
m.treePane = newPane.(filetreepane.Pane)
}
// Blur all other panes
if m.activePane != PaneLayer {
newPane, _ := m.layersPane.Update(layers.FocusStateMsg{Focused: false})
m.layersPane = newPane.(layers.Pane)
}
if m.activePane != PaneDetails {
newPane, _ := m.detailsPane.Update(details.FocusStateMsg{Focused: false})
m.detailsPane = newPane.(details.Pane)
}
if m.activePane != PaneImage {
newPane, _ := m.imagePane.Update(imagepane.FocusStateMsg{Focused: false})
m.imagePane = newPane.(imagepane.Pane)
}
if m.activePane != PaneTree {
newPane, _ := m.treePane.Update(filetreepane.FocusStateMsg{Focused: false})
m.treePane = newPane.(filetreepane.Pane)
}
}
@ -439,10 +519,6 @@ func (m Model) View() string {
// Render UI components
statusBar := m.help.View(m.keys)
// Add active pane indicator to status bar
paneName := styles.StatusStyle.Render(fmt.Sprintf(" Active: %s ", m.activePane))
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, strings.Repeat(" ", 5), paneName)
// Render panes directly using their View() methods
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
m.layersPane.View(),

View file

@ -0,0 +1,12 @@
package common
import (
"github.com/wagoodman/dive/dive/image"
)
// LayerSelectedMsg is sent when a layer is selected
// This replaces direct SetLayer() calls with message passing
type LayerSelectedMsg struct {
Layer *image.Layer
LayerIndex int
}

View file

@ -0,0 +1,15 @@
package common
// LayoutMsg contains pane dimensions calculated by the parent
// This replaces direct SetSize() calls with message passing
type LayoutMsg struct {
// For left column panes (Layers, Details, Image)
LeftWidth int
LayersHeight int
DetailsHeight int
ImageHeight int
// For right column pane (Tree)
RightWidth int
TreeHeight int
}

View file

@ -0,0 +1,15 @@
package common
import (
tea "github.com/charmbracelet/bubbletea"
)
// LocalMouseMsg is a mouse message with coordinates transformed to local pane space
// (0, 0) is the top-left corner of the pane's content area (inside borders)
// Parent model is responsible for coordinate transformations - children receive local coords
type LocalMouseMsg struct {
tea.MouseMsg
// Pane-relative coordinates (already transformed by parent)
LocalX int
LocalY int
}

View file

@ -69,13 +69,16 @@ func (r *StatsPartRenderer) GetType() StatsPartType {
// Render renders the stats part as a string
func (r *StatsPartRenderer) Render() string {
var prefix string
switch r.partType {
case StatsPartAdded:
prefix = "+"
case StatsPartModified:
prefix = "~"
case StatsPartRemoved:
prefix = "-"
// Only show prefix for non-zero values
if r.value != 0 {
switch r.partType {
case StatsPartAdded:
prefix = "+"
case StatsPartModified:
prefix = "~"
case StatsPartRemoved:
prefix = "-"
}
}
// Format value with k/M suffixes to fit in 4 chars max (e.g., "+100k")
@ -96,13 +99,16 @@ func (r *StatsPartRenderer) Render() string {
// RenderPlain renders the stats part without any colors (for row highlight)
func (r *StatsPartRenderer) RenderPlain() string {
var prefix string
switch r.partType {
case StatsPartAdded:
prefix = "+"
case StatsPartModified:
prefix = "~"
case StatsPartRemoved:
prefix = "-"
// Only show prefix for non-zero values
if r.value != 0 {
switch r.partType {
case StatsPartAdded:
prefix = "+"
case StatsPartModified:
prefix = "~"
case StatsPartRemoved:
prefix = "-"
}
}
// Format value with k/M suffixes to fit in 4 chars max (e.g., "+100k")

View file

@ -8,14 +8,20 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/common"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils"
"github.com/wagoodman/dive/dive/image"
)
// FocusStateMsg is sent by parent to tell the pane whether it's focused or not
type FocusStateMsg struct {
Focused bool
}
// Pane displays information about a single layer
type Pane struct {
focused bool
focused bool // Set by parent via FocusStateMsg, not by Focus()/Blur() methods
width int
height int
layer *image.Layer
@ -40,21 +46,6 @@ func (m *Pane) SetLayer(layer *image.Layer) {
m.layer = layer
}
// Focus sets the pane as active
func (m *Pane) Focus() {
m.focused = true
}
// Blur sets the pane as inactive
func (m *Pane) Blur() {
m.focused = false
}
// IsFocused returns true if the pane is focused
func (m *Pane) IsFocused() bool {
return m.focused
}
// Init initializes the pane
func (m Pane) Init() tea.Cmd {
return nil
@ -62,7 +53,24 @@ func (m Pane) Init() tea.Cmd {
// Update handles messages
func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Details pane doesn't handle any messages - it's read-only
switch msg := msg.(type) {
case common.LayoutMsg:
// Parent sends layout info instead of calling SetSize()
// Extract what we need from the message
m.SetSize(msg.LeftWidth, msg.DetailsHeight)
return m, nil
case common.LayerSelectedMsg:
// Parent sends layer selection via message instead of calling SetLayer()
m.SetLayer(msg.Layer)
return m, nil
case FocusStateMsg:
// Parent controls focus state - this is the Single Source of Truth pattern
m.focused = msg.Focused
return m, nil
}
// Details pane doesn't handle any other messages - it's read-only
return m, nil
}

View file

@ -0,0 +1,174 @@
package filetree
import (
"fmt"
"io"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils"
"github.com/wagoodman/dive/dive/filetree"
)
// TreeDelegate handles rendering of a single row in the file tree list
type TreeDelegate struct {
// Can store shared styles here to avoid recreating them
}
func NewTreeDelegate() TreeDelegate {
return TreeDelegate{}
}
func (d TreeDelegate) Height() int {
return 1
}
func (d TreeDelegate) Spacing() int {
return 0
}
func (d TreeDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd {
return nil
}
// Render renders a single row of the file tree
func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
item, ok := listItem.(TreeItem)
if !ok {
return
}
node := item.node
isSelected := index == m.Index()
// 1. Icon and base color
icon := styles.IconFile
diffIcon := "" // 1 space (compact, like nvim-tree)
color := styles.DiffNormalColor
if node.Data.FileInfo.IsDir() {
if node.Data.ViewInfo.Collapsed {
icon = styles.IconDirClosed
} else {
icon = styles.IconDirOpen
}
} else if node.Data.FileInfo.TypeFlag == 16 { // Symlink
icon = styles.IconSymlink
}
// Color for Diff status
switch node.Data.DiffType {
case filetree.Added:
color = styles.DiffAddedColor
case filetree.Removed:
color = styles.DiffRemovedColor
case filetree.Modified:
color = styles.DiffModifiedColor
}
// 2. Metadata (size, permissions)
perm := FormatPermissions(node.Data.FileInfo.Mode)
uidGid := "-"
if node.Data.FileInfo.Uid != 0 || node.Data.FileInfo.Gid != 0 {
uidGid = FormatUidGid(node.Data.FileInfo.Uid, node.Data.FileInfo.Gid)
}
var sizeStr string
if !node.Data.FileInfo.IsDir() {
sizeStr = utils.FormatSize(uint64(node.Data.FileInfo.Size))
}
// 3. Style the name
name := node.Name
if name == "" {
name = "/"
}
if node.Data.FileInfo.TypeFlag == 16 && node.Data.FileInfo.Linkname != "" {
name += " → " + node.Data.FileInfo.Linkname
}
nameStyle := lipgloss.NewStyle().Foreground(color)
if isSelected {
nameStyle = nameStyle.Bold(true).Foreground(styles.PrimaryColor)
}
// 4. Build the row (similar to old code, but with list.Model width)
width := m.Width()
if width <= 0 {
width = 80
}
// Metadata block (fixed width)
metaColor := lipgloss.Color("#6e6e73")
metaBg := lipgloss.Color("")
if isSelected {
metaBg = lipgloss.Color("#1C1C1E") // Dark background for selected row
}
// Create cell styles (code similar to old RenderNodeWithCursor)
sizeCell := lipgloss.NewStyle().Width(SizeWidth).Align(lipgloss.Right).Foreground(metaColor).Background(metaBg).Render(sizeStr)
uidCell := lipgloss.NewStyle().Width(UidGidWidth).Align(lipgloss.Right).Foreground(metaColor).Background(metaBg).Render(uidGid)
permCell := lipgloss.NewStyle().Width(PermWidth).Align(lipgloss.Right).Foreground(metaColor).Background(metaBg).Render(perm)
gap := lipgloss.NewStyle().Width(len(MetaGap)).Background(metaBg).Render(MetaGap)
metaBlock := lipgloss.JoinHorizontal(lipgloss.Top, sizeCell, gap, uidCell, gap, permCell)
metaWidth := lipgloss.Width(metaBlock)
// Left part (tree + name)
styledPrefix := styles.TreeGuideStyle.Render(item.prefix)
if isSelected {
styledPrefix = lipgloss.NewStyle().Foreground(styles.DarkGrayColor).Background(metaBg).Render(item.prefix)
}
// FIX: Use lipgloss.Width for styled strings (ignores ANSI codes)
prefixWidth := lipgloss.Width(styledPrefix)
iconWidth := lipgloss.Width(icon)
// diffIcon is currently empty, but if used, measure with lipgloss.Width
fixedLeftWidth := prefixWidth + iconWidth
// Calculate space for name
availableForName := width - fixedLeftWidth - metaWidth - 1
displayName := name
if runewidth.StringWidth(name) > availableForName && availableForName > 0 {
displayName = runewidth.Truncate(name, availableForName, "…")
}
styledName := nameStyle.Background(metaBg).Render(displayName)
nameWidth := lipgloss.Width(styledName)
// Icons with background
styledIcon := icon
if isSelected {
styledIcon = lipgloss.NewStyle().Background(metaBg).Render(icon)
}
// Apply background to diffIcon and icon if selected
if isSelected {
bg := lipgloss.Color("#1C1C1E")
if diffIcon != "" {
diffIcon = lipgloss.NewStyle().Background(bg).Render(diffIcon)
}
icon = lipgloss.NewStyle().Background(bg).Render(icon)
}
// Padding between name and metadata
// FIX: Use lipgloss.Width for styled strings
contentWidth := prefixWidth + iconWidth + nameWidth + metaWidth
paddingNeeded := width - contentWidth
if paddingNeeded < 1 {
paddingNeeded = 1
}
padding := strings.Repeat(" ", paddingNeeded)
if isSelected {
padding = lipgloss.NewStyle().Background(metaBg).Render(padding)
}
// Final assembly
fmt.Fprintf(w, "%s%s%s%s%s%s", styledPrefix, diffIcon, styledIcon, styledName, padding, metaBlock)
}

View file

@ -1,182 +0,0 @@
package filetree
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout"
)
// InputHandler handles keyboard and mouse input events
type InputHandler struct {
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
func NewInputHandler(nav *Navigation, sel *Selection, vp *ViewportManager, treeVM *viewmodel.FileTreeViewModel) *InputHandler {
return &InputHandler{
navigation: nav,
selection: sel,
viewportMgr: vp,
treeVM: treeVM,
focused: false,
width: 80,
height: 20,
}
}
// SetFocused updates the focused state
func (h *InputHandler) SetFocused(focused bool) {
h.focused = focused
}
// SetSize updates the dimensions
func (h *InputHandler) SetSize(width, height int) {
h.width = width
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) {
if !h.focused {
return nil, false
}
switch msg.String() {
case "up", "k":
return []tea.Cmd{h.navigation.MoveUp()}, true
case "down", "j":
return []tea.Cmd{h.navigation.MoveDown()}, true
case "pgup":
return []tea.Cmd{h.navigation.MovePageUp()}, true
case "pgdown":
return []tea.Cmd{h.navigation.MovePageDown()}, true
case "home":
return []tea.Cmd{h.navigation.MoveToTop()}, true
case "end":
return []tea.Cmd{h.navigation.MoveToBottom()}, true
case "left", "h":
cmd := h.navigation.MoveLeft()
if cmd != nil {
return []tea.Cmd{cmd}, true
}
return nil, true
case "right", "l":
cmd := h.navigation.MoveRight()
if cmd != nil {
return []tea.Cmd{cmd}, true
}
return nil, true
case "enter", " ":
cmd := h.toggleCollapse()
if cmd != nil {
return []tea.Cmd{cmd}, true
}
return nil, true
}
return nil, false
}
// HandleMouseClick processes mouse click events
func (h *InputHandler) HandleMouseClick(msg tea.MouseMsg) tea.Cmd {
x, y := msg.X, msg.Y
// Bounds check
if x < 0 || x >= h.width || y < 0 {
return nil
}
// CRITICAL: Account for the table header row
const tableHeaderHeight = 1
relativeY := y - layout.ContentVisualOffset - tableHeaderHeight
if relativeY < 0 || relativeY >= h.viewportMgr.GetHeight() {
return nil
}
if h.treeVM == nil || h.treeVM.ViewTree == nil {
return nil
}
visibleNodes := CollectVisibleNodes(h.treeVM.ViewTree.Root)
targetIndex := relativeY + h.viewportMgr.GetYOffset()
if targetIndex >= 0 && targetIndex < len(visibleNodes) {
// First click: just focus (move cursor)
// Second click on same row: toggle collapse
if h.selection.GetTreeIndex() == targetIndex {
return h.toggleCollapse()
}
h.selection.MoveToIndex(targetIndex)
h.navigation.SyncScroll()
h.navigation.Refresh()
return func() tea.Msg {
return TreeSelectionChangedMsg{NodeIndex: h.selection.GetTreeIndex()}
}
}
return nil
}
// 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
}
visibleNodes := CollectVisibleNodes(h.treeVM.ViewTree.Root)
treeIndex := h.selection.GetTreeIndex()
if treeIndex >= len(visibleNodes) {
h.selection.MoveToIndex(len(visibleNodes) - 1)
treeIndex = h.selection.GetTreeIndex()
}
if treeIndex < 0 {
h.selection.SetTreeIndex(0)
treeIndex = h.selection.GetTreeIndex()
}
if treeIndex < len(visibleNodes) {
selectedNode := 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
h.navigation.Refresh()
return func() tea.Msg {
return NodeToggledMsg{NodeIndex: treeIndex}
}
}
}
return nil
}

View file

@ -0,0 +1,34 @@
package filetree
import (
"github.com/charmbracelet/bubbles/list"
"github.com/wagoodman/dive/dive/filetree"
)
// TreeItem wraps VisibleNode for compatibility with bubbles/list
type TreeItem struct {
node *filetree.FileNode
prefix string // Tree graphic prefix (│ ├── )
}
// FilterValue returns the value for fuzzy search (built into list)
func (i TreeItem) FilterValue() string {
return i.node.Name
}
// ID returns a unique identifier for the item
func (i TreeItem) ID() string {
return i.node.Path()
}
// ConvertToItems converts VisibleNode slices to list.Item slices
func ConvertToItems(nodes []VisibleNode) []list.Item {
items := make([]list.Item, len(nodes))
for i, n := range nodes {
items[i] = TreeItem{
node: n.Node,
prefix: n.Prefix,
}
}
return items
}

View file

@ -1,246 +0,0 @@
package filetree
import (
tea "github.com/charmbracelet/bubbletea"
)
// Navigation handles tree navigation movements
type Navigation struct {
selection *Selection
viewportMgr *ViewportManager
visibleNodesFn func() []VisibleNode
refreshFn func()
toggleCollapseFn func() tea.Cmd
}
// NewNavigation creates a new navigation handler
func NewNavigation(selection *Selection, viewportMgr *ViewportManager) *Navigation {
return &Navigation{
selection: selection,
viewportMgr: viewportMgr,
}
}
// SetVisibleNodesFunc sets the callback to get visible nodes
func (n *Navigation) SetVisibleNodesFunc(fn func() []VisibleNode) {
n.visibleNodesFn = fn
}
// SetRefreshFunc sets the callback to refresh content
func (n *Navigation) SetRefreshFunc(fn func()) {
n.refreshFn = fn
}
// SetToggleCollapseFunc sets the callback to toggle directory collapse state
func (n *Navigation) SetToggleCollapseFunc(fn func() tea.Cmd) {
n.toggleCollapseFn = fn
}
// MoveUp moves selection up
func (n *Navigation) MoveUp() tea.Cmd {
if n.selection.GetTreeIndex() > 0 {
n.selection.SetTreeIndex(n.selection.GetTreeIndex() - 1)
n.SyncScroll()
n.doRefresh()
}
return nil
}
// MoveDown moves selection down
func (n *Navigation) MoveDown() tea.Cmd {
if n.visibleNodesFn == nil {
return nil
}
visibleNodes := n.visibleNodesFn()
if n.selection.GetTreeIndex() < len(visibleNodes)-1 {
n.selection.SetTreeIndex(n.selection.GetTreeIndex() + 1)
n.SyncScroll()
n.doRefresh()
}
return nil
}
// MovePageUp moves selection up by one page
func (n *Navigation) MovePageUp() tea.Cmd {
if n.visibleNodesFn == nil {
return nil
}
// Move up by viewport height
pageSize := n.viewportMgr.GetHeight()
if pageSize < 1 {
pageSize = 10
}
newIndex := n.selection.GetTreeIndex() - pageSize
if newIndex < 0 {
newIndex = 0
}
n.selection.MoveToIndex(newIndex)
n.SyncScroll()
n.doRefresh()
return nil
}
// MovePageDown moves selection down by one page
func (n *Navigation) MovePageDown() tea.Cmd {
if n.visibleNodesFn == nil {
return nil
}
visibleNodes := n.visibleNodesFn()
if len(visibleNodes) == 0 {
return nil
}
// Move down by viewport height
pageSize := n.viewportMgr.GetHeight()
if pageSize < 1 {
pageSize = 10
}
newIndex := n.selection.GetTreeIndex() + pageSize
if newIndex >= len(visibleNodes) {
newIndex = len(visibleNodes) - 1
}
n.selection.MoveToIndex(newIndex)
n.SyncScroll()
n.doRefresh()
return nil
}
// MoveToTop moves selection to the first item
func (n *Navigation) MoveToTop() tea.Cmd {
n.selection.SetTreeIndex(0)
n.viewportMgr.GotoTop()
n.SyncScroll()
n.doRefresh()
return nil
}
// MoveToBottom moves selection to the last item
func (n *Navigation) MoveToBottom() tea.Cmd {
if n.visibleNodesFn == nil {
return nil
}
visibleNodes := n.visibleNodesFn()
if len(visibleNodes) == 0 {
return nil
}
n.selection.MoveToIndex(len(visibleNodes) - 1)
n.viewportMgr.GotoBottom()
n.SyncScroll()
n.doRefresh()
return nil
}
// SyncScroll ensures the cursor is always visible
func (n *Navigation) SyncScroll() {
if n.visibleNodesFn == nil {
return
}
visibleNodes := n.visibleNodesFn()
if len(visibleNodes) == 0 {
return
}
n.selection.SetMaxIndex(len(visibleNodes))
n.selection.ValidateBounds()
visibleHeight := n.viewportMgr.GetHeight()
if visibleHeight <= 0 {
return
}
treeIndex := n.selection.GetTreeIndex()
yOffset := n.viewportMgr.GetYOffset()
if treeIndex < yOffset {
n.viewportMgr.SetYOffset(treeIndex)
}
if treeIndex >= yOffset+visibleHeight {
n.viewportMgr.SetYOffset(treeIndex - visibleHeight + 1)
}
}
// doRefresh calls the refresh callback if set
func (n *Navigation) doRefresh() {
if n.refreshFn != nil {
n.refreshFn()
}
}
// Refresh is a public method to trigger content refresh
func (n *Navigation) Refresh() {
n.doRefresh()
}
// MoveLeft navigates to parent directory or collapses current directory
func (n *Navigation) MoveLeft() tea.Cmd {
if n.visibleNodesFn == nil {
return nil
}
visibleNodes := n.visibleNodesFn()
if len(visibleNodes) == 0 {
return nil
}
currentIndex := n.selection.GetTreeIndex()
if currentIndex < 0 || currentIndex >= len(visibleNodes) {
return nil
}
currentNode := visibleNodes[currentIndex].Node
// If current node is an expanded directory, collapse it
if currentNode.Data.FileInfo.IsDir() && !currentNode.Data.ViewInfo.Collapsed {
if n.toggleCollapseFn != nil {
return n.toggleCollapseFn()
}
return nil
}
// Otherwise, move to parent directory (for files or collapsed dirs)
parentIndex := FindParentIndex(visibleNodes, currentIndex)
if parentIndex >= 0 {
n.selection.MoveToIndex(parentIndex)
n.SyncScroll()
n.doRefresh()
}
return nil
}
// MoveRight expands collapsed directory
func (n *Navigation) MoveRight() tea.Cmd {
if n.visibleNodesFn == nil {
return nil
}
visibleNodes := n.visibleNodesFn()
if len(visibleNodes) == 0 {
return nil
}
currentIndex := n.selection.GetTreeIndex()
if currentIndex < 0 || currentIndex >= len(visibleNodes) {
return nil
}
currentNode := visibleNodes[currentIndex].Node
// If current node is a collapsed directory, expand it
if currentNode.Data.FileInfo.IsDir() && currentNode.Data.ViewInfo.Collapsed {
if n.toggleCollapseFn != nil {
return n.toggleCollapseFn()
}
}
return nil
}

View file

@ -15,10 +15,15 @@ func RenderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, prefix s
if node == nil {
return
}
row := RenderRow(node, prefix, isSelected, width)
sb.WriteString(row)
sb.WriteString("\n")
}
// 1. Icon and base color
// RenderRow renders a single tree node row using lipgloss.JoinHorizontal for clean layout
func RenderRow(node *filetree.FileNode, prefix string, isSelected bool, width int) string {
// 1. Icon and color
icon := styles.IconFile
diffIcon := "" // 1 space (compact, like nvim-tree)
color := styles.DiffNormalColor
if node.Data.FileInfo.IsDir() {
@ -31,7 +36,7 @@ func RenderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, prefix s
icon = styles.IconSymlink
}
// 2. Diff status (color only, no icons)
// 2. Diff status color
switch node.Data.DiffType {
case filetree.Added:
color = styles.DiffAddedColor
@ -41,10 +46,9 @@ func RenderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, prefix s
color = styles.DiffModifiedColor
}
// 3. Format metadata (right-aligned)
// 3. Format metadata (fixed width, right-aligned)
perm := FormatPermissions(node.Data.FileInfo.Mode)
// Show UID:GID only if not the default root:root (0:0)
var uidGid string
if node.Data.FileInfo.Uid != 0 || node.Data.FileInfo.Gid != 0 {
uidGid = FormatUidGid(node.Data.FileInfo.Uid, node.Data.FileInfo.Gid)
@ -52,13 +56,12 @@ func RenderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, prefix s
uidGid = "-"
}
// Size (empty for folders)
var sizeStr string
if !node.Data.FileInfo.IsDir() {
sizeStr = utils.FormatSize(uint64(node.Data.FileInfo.Size))
}
// Format name
// Format name with symlink target
name := node.Name
if name == "" {
name = "/"
@ -67,97 +70,64 @@ func RenderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, prefix s
name += " → " + node.Data.FileInfo.Linkname
}
// 4. Build line with new order: cursor | tree-guides diff icon icon filename [metadata...]
// Cursor mark (same as Layers)
cursorMark := ""
// 4. Common background for selected state
bg := lipgloss.Color("")
if isSelected {
cursorMark = ""
bg = lipgloss.Color("#1C1C1E")
}
// Render tree guides (lines should be gray)
// If selected, apply background to tree guides too
styledPrefix := styles.TreeGuideStyle.Render(prefix)
// 5. Build styled components
// Tree guides (prefix)
prefixStyle := lipgloss.NewStyle().Foreground(styles.DarkGrayColor).Background(bg)
styledPrefix := prefixStyle.Render(prefix)
// Icon
iconStyle := lipgloss.NewStyle().Background(bg)
styledIcon := iconStyle.Render(icon)
// Filename with diff color
nameStyle := lipgloss.NewStyle().Foreground(color).Background(bg)
if isSelected {
styledPrefix = lipgloss.NewStyle().
Foreground(styles.DarkGrayColor).
Background(lipgloss.Color("#1C1C1E")).
Render(prefix)
nameStyle = nameStyle.Bold(true).Foreground(styles.PrimaryColor)
}
// Render filename with diff color
nameStyle := lipgloss.NewStyle().Foreground(color)
if isSelected {
// Use SelectedLayerStyle (with background and primary color)
nameStyle = nameStyle.Bold(true).Foreground(styles.PrimaryColor).Background(lipgloss.Color("#1C1C1E"))
}
// Render metadata with FIXED WIDTH columns for strict grid layout
// Each column gets exact width to ensure headers align with data
// Base metadata color
// 6. Render metadata cells (fixed width)
metaColor := lipgloss.Color("#6e6e73")
// If selected, use background color for metadata too
metaBg := lipgloss.Color("")
if isSelected {
metaBg = lipgloss.Color("#1C1C1E")
}
// Create cell styles with fixed width and right alignment
sizeCell := lipgloss.NewStyle().
Width(SizeWidth).
Align(lipgloss.Right).
Foreground(metaColor).
Background(metaBg)
Background(bg).
Render(sizeStr)
uidGidCell := lipgloss.NewStyle().
Width(UidGidWidth).
Align(lipgloss.Right).
Foreground(metaColor).
Background(metaBg)
Background(bg).
Render(uidGid)
permCell := lipgloss.NewStyle().
Width(PermWidth).
Align(lipgloss.Right).
Foreground(metaColor).
Background(metaBg)
Background(bg).
Render(perm)
// Render each cell with fixed width
styledSize := sizeCell.Render(sizeStr)
styledUidGid := uidGidCell.Render(uidGid)
styledPerm := permCell.Render(perm)
gap := lipgloss.NewStyle().Width(len(MetaGap)).Background(bg).Render(MetaGap)
// Gap style (must have background if selected)
gapStyle := lipgloss.NewStyle().Width(len(MetaGap))
if isSelected {
gapStyle = gapStyle.Background(lipgloss.Color("#1C1C1E"))
}
styledGap := gapStyle.Render(MetaGap)
// Join cells horizontally with gap
// This creates a rigid block where each column has exact width
// Metadata block (right-aligned columns)
metaBlock := lipgloss.JoinHorizontal(
lipgloss.Top,
styledSize,
styledGap,
styledUidGid,
styledGap,
styledPerm,
sizeCell, gap, uidGidCell, gap, permCell,
)
// Calculate widths for truncation
// Fixed part: cursor + prefix + diffIcon + icon
fixedPartWidth := runewidth.StringWidth(cursorMark) +
runewidth.StringWidth(prefix) +
runewidth.StringWidth(diffIcon) +
runewidth.StringWidth(icon)
// Get actual metadata block width (should be: sizeWidth + gap + uidGidWidth + gap + permWidth)
// 7. Calculate available width for filename
fixedPartWidth := lipgloss.Width(styledPrefix) + lipgloss.Width(styledIcon)
metaBlockWidth := lipgloss.Width(metaBlock)
// Available width for filename (between file and right-aligned metadata)
availableForName := width - fixedPartWidth - metaBlockWidth - 2 // -2 for gaps
availableForName := width - fixedPartWidth - metaBlockWidth - 2 // -2 for spacing
if availableForName < 5 {
availableForName = 5
}
@ -167,45 +137,24 @@ func RenderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, prefix s
if runewidth.StringWidth(name) > availableForName {
displayName = runewidth.Truncate(name, availableForName, "…")
}
styledName := nameStyle.Render(displayName)
// Apply background to diffIcon and icon if selected
if isSelected {
bg := lipgloss.Color("#1C1C1E")
if diffIcon != "" {
diffIcon = lipgloss.NewStyle().Background(bg).Render(diffIcon)
}
icon = lipgloss.NewStyle().Background(bg).Render(icon)
}
// Calculate EXACT padding to push metadata to the right edge
// Current content width (without spacer)
currentContentWidth := fixedPartWidth + runewidth.StringWidth(displayName) + metaBlockWidth
// How many spaces needed to fill to width?
paddingNeeded := width - currentContentWidth
// 8. Calculate flexible padding to push metadata to right edge
contentWidth := fixedPartWidth + lipgloss.Width(styledName) + metaBlockWidth
paddingNeeded := width - contentWidth
if paddingNeeded < 1 {
paddingNeeded = 1 // At least 1 space gap
paddingNeeded = 1
}
// If selected, padding should also have background
paddingStyle := lipgloss.NewStyle()
if isSelected {
paddingStyle = paddingStyle.Background(lipgloss.Color("#1C1C1E"))
}
padding := paddingStyle.Render(strings.Repeat(" ", paddingNeeded))
padding := lipgloss.NewStyle().Width(paddingNeeded).Background(bg).Render(strings.Repeat(" ", paddingNeeded))
// Assemble final line: tree-guides cursor diff icon filename [SPACER] metadata
sb.WriteString(styledPrefix)
sb.WriteString(cursorMark)
sb.WriteString(diffIcon)
sb.WriteString(icon)
sb.WriteString(styledName)
sb.WriteString(padding) // <--- THIS PUSHES METADATA TO THE RIGHT EDGE
sb.WriteString(metaBlock)
sb.WriteString("\n")
// Note: Filename comes first, metadata is right-aligned at the end
// Order: filename → size → uid:gid → permissions
// 9. Join all components horizontally
return lipgloss.JoinHorizontal(
lipgloss.Top,
styledPrefix,
styledIcon,
styledName,
padding,
metaBlock,
)
}

View file

@ -1,16 +1,20 @@
package filetree
import (
"strings"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/bubbles/list"
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"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/common"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
)
// FocusStateMsg is sent by parent to tell the pane whether it's focused or not
type FocusStateMsg struct {
Focused bool
}
// NodeToggledMsg is sent when a tree node is collapsed/expanded
type NodeToggledMsg struct {
NodeIndex int
@ -26,309 +30,271 @@ type RefreshTreeContentMsg struct {
LayerIndex int
}
// Pane manages the file tree
// Pane manages the file tree using bubbles/list for automatic scrolling and navigation
type Pane struct {
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
inputHandler *InputHandler
// list.Model handles scrolling, cursor, and viewport automatically
list list.Model
}
// New creates a new tree pane
// New creates a new tree pane with bubbles/list
func New(treeVM *viewmodel.FileTreeViewModel) Pane {
// Initialize components
selection := NewSelection()
viewportMgr := NewViewportManager(80, 20)
navigation := NewNavigation(selection, viewportMgr)
inputHandler := NewInputHandler(navigation, selection, viewportMgr, treeVM)
// Initialize list with custom delegate
delegate := NewTreeDelegate()
l := list.New([]list.Item{}, delegate, 0, 0)
// Configure list appearance
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetFilteringEnabled(false)
l.SetShowPagination(false) // Disable bubbles pagination, we scroll ourselves
// Custom key bindings for page navigation
l.KeyMap.NextPage.SetKeys("pgdown", " ", "f")
l.KeyMap.PrevPage.SetKeys("pgup", "b")
p := Pane{
treeVM: treeVM,
selection: selection,
viewportMgr: viewportMgr,
navigation: navigation,
inputHandler: inputHandler,
focused: false,
width: 80,
height: 20,
visibleNodes: []VisibleNode{},
treeVM: treeVM,
focused: false,
width: 80,
height: 20,
list: l,
}
// 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
}
return CollectVisibleNodes(p.treeVM.ViewTree.Root)
})
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()
// Build initial list items
p.rebuildListItems()
return p
}
// SetSize updates the pane dimensions
func (m *Pane) SetSize(width, height int) {
m.width = width
m.height = height
m.inputHandler.SetSize(width, height)
func (p *Pane) SetSize(width, height int) {
p.width = width
p.height = height
viewportWidth := width - 2
// Calculate available height for the list content
// Layout Padding: 2 (Top Border) + 2 (Bottom Border/Title gap) = 4
// Header visual height: 1 (not layout.TreeTableHeaderHeight which is 3)
const visualHeaderHeight = 1
// 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
availableHeight := height - layout.BoxContentPadding - visualHeaderHeight
if availableHeight < 0 {
availableHeight = 0
}
m.viewportMgr.SetSize(viewportWidth, viewportHeight)
// CRITICAL: Regenerate content with new width to prevent soft wrap
// Without this, long paths will wrap when window is resized
m.updateContent()
// Update list size (handles viewport automatically)
p.list.SetSize(width-2, availableHeight)
}
// 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()
func (p *Pane) SetTreeVM(treeVM *viewmodel.FileTreeViewModel) {
p.treeVM = treeVM
p.rebuildListItems()
p.list.Select(0)
}
// SetTreeIndex sets the current tree index
func (m *Pane) SetTreeIndex(index int) {
m.selection.SetTreeIndex(index)
m.navigation.SyncScroll()
func (p *Pane) SetTreeIndex(index int) {
p.list.Select(index)
}
// GetTreeIndex returns the current tree index
func (m *Pane) GetTreeIndex() int {
return m.selection.GetTreeIndex()
}
// Focus sets the pane as active
func (m *Pane) Focus() {
m.focused = true
m.inputHandler.SetFocused(true)
}
// Blur sets the pane as inactive
func (m *Pane) Blur() {
m.focused = false
m.inputHandler.SetFocused(false)
}
// IsFocused returns true if the pane is focused
func (m *Pane) IsFocused() bool {
return m.focused
func (p *Pane) GetTreeIndex() int {
return p.list.Index()
}
// Init initializes the pane
func (m Pane) Init() tea.Cmd {
m.updateContent()
func (p Pane) Init() tea.Cmd {
return nil
}
// Update handles messages
func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (p Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
cmds, consumed := m.inputHandler.HandleKeyPress(msg)
if consumed {
// Don't pass to viewport
return m, tea.Batch(cmds...)
}
case common.LayoutMsg:
p.SetSize(msg.RightWidth, msg.TreeHeight)
return p, nil
case tea.MouseMsg:
case FocusStateMsg:
p.focused = msg.Focused
return p, nil
case common.LocalMouseMsg:
// Handle mouse events manually since bubbles/list doesn't understand LocalMouseMsg
if msg.Action == tea.MouseActionPress {
var keyCmds []tea.Cmd
switch msg.Button {
case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown:
// IMPORTANT: For scrolling, pass the ORIGINAL message (msg.MouseMsg)
// directly to the list component. bubbles/list knows how to properly
// scroll the viewport when it receives standard wheel events.
// Using CursorUp/Down here was incorrect as they only move the cursor,
// not the viewport.
var cmd tea.Cmd
p.list, cmd = p.list.Update(msg.MouseMsg)
return p, cmd
if msg.Button == tea.MouseButtonWheelUp {
keyCmds = append(keyCmds, m.navigation.MoveUp())
} else if msg.Button == tea.MouseButtonWheelDown {
keyCmds = append(keyCmds, m.navigation.MoveDown())
}
case tea.MouseButtonLeft:
// Calculate item index from Y coordinate
// Content offset consists of:
// 1 (top border) + 1 (box title) + 1 (empty line) + 1 (table header) = 4
const contentOffsetY = 4
if msg.Button == tea.MouseButtonLeft {
if cmd := m.inputHandler.HandleMouseClick(msg); cmd != nil {
keyCmds = append(keyCmds, cmd)
// Local Y coordinate within the list
clickY := msg.LocalY - contentOffsetY
// Ignore clicks above/below the list content
if clickY >= 0 {
// Calculate absolute index of the item in the list
// Start() returns the index of the first visible item
// (p.list.Index() - p.list.Cursor()) is the index of the top item
firstVisibleIndex := p.list.Index() - p.list.Cursor()
targetIndex := firstVisibleIndex + clickY
// Check bounds
if targetIndex >= 0 && targetIndex < len(p.list.Items()) {
p.list.Select(targetIndex)
// Click selects file and toggles folder
// Send selection changed message and toggle command
return p, tea.Batch(
func() tea.Msg { return TreeSelectionChangedMsg{NodeIndex: targetIndex} },
p.toggleCollapse(),
)
}
}
}
if len(keyCmds) > 0 {
return m, tea.Batch(keyCmds...)
}
}
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 tea.KeyMsg:
if !p.focused {
return p, nil
}
case RefreshTreeContentMsg:
m.updateContent()
// Handle special keys before delegating to list
switch msg.String() {
case "enter", "space", "right", "l":
// Toggle folder collapse/expand
return p, p.toggleCollapse()
case "left", "h":
return p, p.handleLeftKey()
case "up", "k":
// Let list handle navigation
case "down", "j":
// Let list handle navigation
case "home", "g":
p.list.Select(0)
return p, nil
case "end", "G":
items := p.list.Items()
if len(items) > 0 {
p.list.Select(len(items) - 1)
}
return p, nil
}
case NodeToggledMsg, RefreshTreeContentMsg:
p.rebuildListItems()
}
// Always update viewport
_, cmd := m.viewportMgr.Update(msg)
// Delegate all other messages to list (handles navigation, scrolling, mouse)
var cmd tea.Cmd
p.list, cmd = p.list.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
return p, tea.Batch(cmds...)
}
// View renders the pane
func (m Pane) View() string {
// 1. Generate static header
header := RenderHeader(m.width)
func (p Pane) View() string {
// 1. Static table header
header := RenderHeader(p.width)
// 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()
// 2. List view (bubbles/list renders only visible items)
listView := p.list.View()
// 3. Combine: Header + Content
fullContent := lipgloss.JoinVertical(lipgloss.Left, header, content)
// 3. Combine header and list
fullContent := lipgloss.JoinVertical(lipgloss.Left, header, listView)
return styles.RenderBox("Current Layer Contents", m.width, m.height, fullContent, m.focused)
return styles.RenderBox("Current Layer Contents", p.width, p.height, fullContent, p.focused)
}
// updateContent refreshes the cache and updates the viewport scroll bounds
func (m *Pane) updateContent() {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
m.visibleNodes = nil
m.viewportMgr.SetContent("No tree data")
// ========================================
// TREE OPERATIONS
// ========================================
// rebuildListItems rebuilds the list when tree structure changes
func (p *Pane) rebuildListItems() {
if p.treeVM == nil || p.treeVM.ViewTree == nil {
p.list.SetItems(nil)
return
}
// 1. Cache the visible nodes structure (fast pointer traversal)
m.visibleNodes = CollectVisibleNodes(m.treeVM.ViewTree.Root)
// Flatten tree structure into visible nodes
nodes := CollectVisibleNodes(p.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("")
}
// Convert to list.Item slice
items := ConvertToItems(nodes)
p.list.SetItems(items)
}
// 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
viewportWidth := m.viewportMgr.GetViewport().Width
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()
}
// toggleCollapse toggles the current node's collapse state
func (m *Pane) toggleCollapse() tea.Cmd {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
// toggleCollapse toggles the collapsed state of the selected directory
func (p *Pane) toggleCollapse() tea.Cmd {
item := p.list.SelectedItem()
if item == nil {
return nil
}
// Use cached nodes for index lookup
if len(m.visibleNodes) == 0 {
treeItem := item.(TreeItem)
node := treeItem.node
// Only directories can be collapsed/expanded
if node.Data.FileInfo.IsDir() {
node.Data.ViewInfo.Collapsed = !node.Data.ViewInfo.Collapsed
p.rebuildListItems()
// Preserve selection position if possible
currentIndex := p.list.Index()
if currentIndex >= 0 && currentIndex < len(p.list.Items()) {
p.list.Select(currentIndex)
}
return func() tea.Msg {
return NodeToggledMsg{NodeIndex: p.list.Index()}
}
}
return nil
}
// handleLeftKey handles left arrow key behavior
func (p *Pane) handleLeftKey() tea.Cmd {
item := p.list.SelectedItem()
if item == nil {
return nil
}
treeIndex := m.selection.GetTreeIndex()
treeItem := item.(TreeItem)
node := treeItem.node
// Bounds check
if treeIndex >= len(m.visibleNodes) {
m.selection.MoveToIndex(len(m.visibleNodes) - 1)
treeIndex = m.selection.GetTreeIndex()
}
if treeIndex < 0 {
m.selection.SetTreeIndex(0)
treeIndex = m.selection.GetTreeIndex()
// If current node is an expanded directory, collapse it
if node.Data.FileInfo.IsDir() && !node.Data.ViewInfo.Collapsed {
return p.toggleCollapse()
}
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
// Refresh content (re-collect nodes and update viewport bounds)
m.updateContent()
return func() tea.Msg {
return NodeToggledMsg{NodeIndex: treeIndex}
// Otherwise, navigate to parent directory
if node.Parent != nil {
items := p.list.Items()
for i, it := range items {
if it.(TreeItem).node == node.Parent {
p.list.Select(i)
return func() tea.Msg {
return TreeSelectionChangedMsg{NodeIndex: i}
}
}
}
}
@ -336,7 +302,7 @@ func (m *Pane) toggleCollapse() tea.Cmd {
return nil
}
// GetViewport returns the underlying viewport
func (m *Pane) GetViewport() *viewport.Model {
return m.viewportMgr.GetViewport()
// GetList returns the underlying list model
func (p *Pane) GetList() *list.Model {
return &p.list
}

View file

@ -1,51 +0,0 @@
package filetree
// Selection manages the tree index selection state
type Selection struct {
treeIndex int
maxIndex int
}
// NewSelection creates a new selection with default values
func NewSelection() *Selection {
return &Selection{
treeIndex: 0,
maxIndex: 0,
}
}
// SetTreeIndex sets the current tree index directly
func (s *Selection) SetTreeIndex(index int) {
s.treeIndex = index
}
// GetTreeIndex returns the current tree index
func (s *Selection) GetTreeIndex() int {
return s.treeIndex
}
// MoveToIndex moves selection to the specified index
func (s *Selection) MoveToIndex(index int) {
s.treeIndex = index
s.ValidateBounds()
}
// SetMaxIndex updates the maximum valid index
func (s *Selection) SetMaxIndex(max int) {
s.maxIndex = max
}
// GetMaxIndex returns the maximum valid index
func (s *Selection) GetMaxIndex() int {
return s.maxIndex
}
// ValidateBounds ensures treeIndex is within [0, maxIndex]
func (s *Selection) ValidateBounds() {
if s.treeIndex >= s.maxIndex && s.maxIndex > 0 {
s.treeIndex = s.maxIndex - 1
}
if s.treeIndex < 0 {
s.treeIndex = 0
}
}

View file

@ -1,65 +0,0 @@
package filetree
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/viewport"
)
// ViewportManager wraps bubbletea viewport with typed methods
type ViewportManager struct {
viewport viewport.Model
}
// NewViewportManager creates a new viewport manager with the given dimensions
func NewViewportManager(width, height int) *ViewportManager {
vp := viewport.New(width, height)
return &ViewportManager{
viewport: vp,
}
}
// SetSize updates the viewport dimensions
func (v *ViewportManager) SetSize(width, height int) {
v.viewport.Width = width
v.viewport.Height = height
}
// SetContent updates the viewport content
func (v *ViewportManager) SetContent(content string) {
v.viewport.SetContent(content)
}
// GetViewport returns the underlying viewport model
func (v *ViewportManager) GetViewport() *viewport.Model {
return &v.viewport
}
// GotoTop scrolls to the top of the viewport
func (v *ViewportManager) GotoTop() {
v.viewport.GotoTop()
}
// GotoBottom scrolls to the bottom of the viewport
func (v *ViewportManager) GotoBottom() {
v.viewport.GotoBottom()
}
// SetYOffset sets the vertical scroll offset
func (v *ViewportManager) SetYOffset(offset int) {
v.viewport.SetYOffset(offset)
}
// GetYOffset returns the current vertical scroll offset
func (v *ViewportManager) GetYOffset() int {
return v.viewport.YOffset
}
// GetHeight returns the viewport height
func (v *ViewportManager) GetHeight() int {
return v.viewport.Height
}
// Update passes a message to the underlying viewport
func (v *ViewportManager) Update(msg tea.Msg) (viewport.Model, tea.Cmd) {
return v.viewport.Update(msg)
}

View file

@ -10,14 +10,20 @@ import (
"github.com/mattn/go-runewidth"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/common"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils"
"github.com/wagoodman/dive/dive/image"
)
// FocusStateMsg is sent by parent to tell the pane whether it's focused or not
type FocusStateMsg struct {
Focused bool
}
// Pane displays image-level statistics and inefficiencies
type Pane struct {
focused bool
focused bool // Set by parent via FocusStateMsg, not by Focus()/Blur() methods
width int
height int
analysis *image.Analysis
@ -62,21 +68,6 @@ func (m *Pane) SetAnalysis(analysis *image.Analysis) {
m.updateContent()
}
// Focus sets the pane as active
func (m *Pane) Focus() {
m.focused = true
}
// Blur sets the pane as inactive
func (m *Pane) Blur() {
m.focused = false
}
// IsFocused returns true if the pane is focused
func (m *Pane) IsFocused() bool {
return m.focused
}
// Init initializes the pane
func (m Pane) Init() tea.Cmd {
m.updateContent()
@ -88,6 +79,17 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case common.LayoutMsg:
// Parent sends layout info instead of calling SetSize()
// Extract what we need from the message
m.SetSize(msg.LeftWidth, msg.ImageHeight)
return m, nil
case FocusStateMsg:
// Parent controls focus state - this is the Single Source of Truth pattern
m.focused = msg.Focused
return m, nil
case tea.KeyMsg:
if !m.focused {
return m, nil
@ -164,12 +166,14 @@ func (m *Pane) generateContent() string {
if len(m.analysis.Inefficiencies) > 0 {
for _, file := range m.analysis.Inefficiencies {
row := fmt.Sprintf("%-5d %-12s %s", len(file.Nodes), utils.FormatSize(uint64(file.CumulativeSize)), file.Path)
if lipgloss.Width(row) > width {
row = runewidth.Truncate(row, width, "...")
if file.CumulativeSize > 0 {
row := fmt.Sprintf("%-5d %-12s %s", len(file.Nodes), utils.FormatSize(uint64(file.CumulativeSize)), file.Path)
if lipgloss.Width(row) > width {
row = runewidth.Truncate(row, width, "...")
}
fullContent.WriteString(styles.FileTreeModifiedStyle.Render(row))
fullContent.WriteString("\n")
}
fullContent.WriteString(styles.FileTreeModifiedStyle.Render(row))
fullContent.WriteString("\n")
}
} else {
fullContent.WriteString("No inefficiencies detected - great job!")

View file

@ -11,6 +11,7 @@ import (
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/common"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/components"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils"
@ -28,6 +29,11 @@ type ShowLayerDetailMsg struct {
Layer *image.Layer
}
// FocusStateMsg is sent by parent to tell the pane whether it's focused or not
type FocusStateMsg struct {
Focused bool
}
// Define layout constants to ensure click detection matches rendering
const (
ColWidthPrefix = 7 // "[1/n] " format (max 6 chars + space)
@ -41,7 +47,7 @@ const (
// Pane manages the layers list
type Pane struct {
focused bool
focused bool // Set by parent via FocusStateMsg, not by Focus()/Blur() methods
width int
height int
layerVM *viewmodel.LayerSetState
@ -49,6 +55,7 @@ type Pane struct {
viewport viewport.Model
layerIndex int
statsRows []components.FileStatsRow // Stats row for each layer
statsCache []utils.FileStats // Cached statistics for each layer (calculated once)
}
// New creates a new layers pane
@ -74,10 +81,55 @@ func New(layerVM *viewmodel.LayerSetState, comparer filetree.Comparer) Pane {
statsRows: statsRows,
}
// IMPORTANT: Generate content immediately so viewport is not empty on startup
// BUT: First calculate stats to avoid heavy computation in View()
p.precalculateStats()
p.updateContent()
return p
}
// precalculateStats calculates file statistics for all layers once
// This is called during initialization to avoid expensive tree traversal during rendering
func (m *Pane) precalculateStats() {
if m.layerVM == nil || len(m.layerVM.Layers) == 0 {
m.statsCache = nil
return
}
// Pre-allocate cache for all layers
m.statsCache = make([]utils.FileStats, len(m.layerVM.Layers))
// Calculate stats for each layer
for i, layer := range m.layerVM.Layers {
var treeToCompare *filetree.FileTree
// Use comparer to get the comparison tree for this layer
// For layer i, we want to show changes from layer i-1 to i (or 0 to i for first layer)
if m.comparer != nil {
bottomTreeStart := 0
bottomTreeStop := i - 1
if bottomTreeStop < 0 {
bottomTreeStop = i
}
topTreeStart := i
topTreeStop := i
key := filetree.NewTreeIndexKey(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop)
comparisonTree, err := m.comparer.GetTree(key)
if err == nil && comparisonTree != nil {
treeToCompare = comparisonTree
}
}
// Fallback to layer.Tree if comparer didn't work
if treeToCompare == nil {
treeToCompare = layer.Tree
}
// Calculate stats ONCE per layer (heavy tree traversal)
m.statsCache[i] = utils.CalculateFileStats(treeToCompare)
}
}
// SetSize updates the pane dimensions
func (m *Pane) SetSize(width, height int) {
m.width = width
@ -120,21 +172,6 @@ func (m *Pane) SetLayerIndex(index int) tea.Cmd {
}
}
// Focus sets the pane as active
func (m *Pane) Focus() {
m.focused = true
}
// Blur sets the pane as inactive
func (m *Pane) Blur() {
m.focused = false
}
// IsFocused returns true if the pane is focused
func (m *Pane) IsFocused() bool {
return m.focused
}
// Init initializes the pane
func (m Pane) Init() tea.Cmd {
m.updateContent()
@ -146,11 +183,18 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if !m.focused {
return m, nil
}
case common.LayoutMsg:
// Parent sends layout info instead of calling SetSize()
// Extract what we need from the message
m.SetSize(msg.LeftWidth, msg.LayersHeight)
return m, nil
case FocusStateMsg:
// Parent controls focus state - this is the Single Source of Truth pattern
m.focused = msg.Focused
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "up", "k", "[":
cmds = append(cmds, m.moveUp())
@ -165,20 +209,18 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case tea.MouseMsg:
// Mouse wheel
case common.LocalMouseMsg:
// Mouse coordinates are already transformed by parent to local pane space
// LocalX, LocalY are relative to the pane's content area (inside borders)
if msg.Action == tea.MouseActionPress {
if msg.Button == tea.MouseButtonWheelUp {
cmds = append(cmds, m.moveUp())
} else if msg.Button == tea.MouseButtonWheelDown {
cmds = append(cmds, m.moveDown())
}
}
// Left click - select layer
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
if cmd := m.handleClick(msg.X, msg.Y); cmd != nil {
cmds = append(cmds, cmd)
} else if msg.Button == tea.MouseButtonLeft {
if cmd := m.handleClick(msg.LocalX, msg.LocalY); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
}
@ -227,35 +269,25 @@ func (m *Pane) moveDown() tea.Cmd {
}
}
// handleClick processes a mouse click
// handleClick processes a mouse click with LOCAL coordinates
// x, y are provided by parent:
// - x: relative to pane border (X=0 is at the left border)
// - y: relative to content area (Y=0 is at first line of content, accounting for viewport scroll)
func (m *Pane) handleClick(x, y int) tea.Cmd {
// 1. Basic Bounds Check
if x < 0 || x >= m.width || y < 0 {
return nil
}
// 2. Adjust Y for Viewport scrolling and Header
relativeY := y - layout.ContentVisualOffset
if relativeY < 0 || relativeY >= m.viewport.Height {
return nil
}
targetIndex := relativeY + m.viewport.YOffset
if targetIndex < 0 || targetIndex >= len(m.layerVM.Layers) {
return nil
}
// 3. Adjust X for Border
// The pane is rendered with RenderBox, which adds 1 char border on the left.
// So the content technically starts at X=1 relative to the pane.
// We subtract 1 to get the X coordinate relative to the *content*.
// Account for the left border (X=1 is first column of content)
contentX := x - 1
if contentX < 0 {
return nil
}
// 4. Check if click is in stats area
// We use the shared constant StatsStartOffset to ensure math matches GenerateContent
// Y is already relative to the content area, but we need to account for viewport scrolling
// The parent has already accounted for ContentVisualOffset, so y starts at 0 for the first visible line
targetIndex := y + m.viewport.YOffset
if targetIndex < 0 || targetIndex >= len(m.layerVM.Layers) {
return nil
}
// Check if click is in stats area
if targetIndex < len(m.statsRows) {
partType, found := m.statsRows[targetIndex].GetPartAtPosition(contentX, StatsStartOffset)
if found {
@ -269,7 +301,7 @@ func (m *Pane) handleClick(x, y int) tea.Cmd {
}
}
// 5. Click outside stats - select layer
// Click outside stats - select layer
return m.SetLayerIndex(targetIndex)
}
@ -312,34 +344,10 @@ func (m *Pane) generateContent() string {
// Update and get stats from component
statsStr := ""
if i < len(m.statsRows) {
// Use comparer to get the comparison tree for this layer
// For layer i, we want to show changes from layer i-1 to i (or 0 to i for first layer)
var treeToCompare *filetree.FileTree
if m.comparer != nil {
// Get tree for comparing previous layer (or 0) to current layer
// This follows the CompareSingleLayer mode logic
bottomTreeStart := 0
bottomTreeStop := i - 1
if bottomTreeStop < 0 {
bottomTreeStop = i
}
topTreeStart := i
topTreeStop := i
key := filetree.NewTreeIndexKey(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop)
comparisonTree, err := m.comparer.GetTree(key)
if err == nil && comparisonTree != nil {
treeToCompare = comparisonTree
}
}
// Fallback to layer.Tree if comparer didn't work
if treeToCompare == nil {
treeToCompare = layer.Tree
}
stats := utils.CalculateFileStats(treeToCompare)
if i < len(m.statsRows) && i < len(m.statsCache) {
// PERFOMANCE: Use cached stats instead of recalculating on every render
// This avoids expensive tree traversal (CalculateFileStats) during scrolling
stats := m.statsCache[i]
m.statsRows[i].SetStats(stats)
// Use plain rendering for selected layer to allow background highlight

View file

@ -143,3 +143,7 @@ var TreeGuideStyle = lipgloss.NewStyle().
// MetaDataStyle for permissions, UID, and size (muted, less prominent)
var MetaDataStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6e6e73"))
// HelpStyle for help/instruction bar at the bottom (gray, muted)
var HelpStyle = lipgloss.NewStyle().
Foreground(GrayColor)

View file

@ -0,0 +1,93 @@
// Package zone provides a simple zone manager for handling mouse clicks
// This is a lightweight alternative to external zone libraries
package zone
import (
"sync"
tea "github.com/charmbracelet/bubbletea"
)
// Manager tracks clickable regions in the UI
type Manager struct {
mu sync.RWMutex
zones map[string]Rect
}
// Rect represents a rectangular region
type Rect struct {
X int
Y int
Width int
Height int
}
// New creates a new zone manager
func New() *Manager {
return &Manager{
zones: make(map[string]Rect),
}
}
// Set defines a zone with the given ID and boundaries
func (m *Manager) Set(id string, x, y, width, height int) {
m.mu.Lock()
defer m.mu.Unlock()
m.zones[id] = Rect{X: x, Y: y, Width: width, Height: height}
}
// Get retrieves a zone by ID
func (m *Manager) Get(id string) (Rect, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
rect, ok := m.zones[id]
return rect, ok
}
// Contains checks if a point is within a zone
func (r Rect) Contains(x, y int) bool {
return x >= r.X && x < r.X+r.Width && y >= r.Y && y < r.Y+r.Height
}
// At the given coordinates returns all zone IDs that contain this point
func (m *Manager) At(x, y int) []string {
m.mu.RLock()
defer m.mu.RUnlock()
var ids []string
for id, rect := range m.zones {
if rect.Contains(x, y) {
ids = append(ids, id)
}
}
return ids
}
// Clear removes all zones
func (m *Manager) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.zones = make(map[string]Rect)
}
// QueryMsg is a tea.Msg that requests zone information at coordinates
type QueryMsg struct {
X, Y int
}
// ResponseMsg contains the zone IDs at the queried coordinates
type ResponseMsg struct {
IDs []string
}
// Handler creates a tea.Cmd that responds to QueryMsg
func (m *Manager) Handler() func(tea.Msg) *ResponseMsg {
return func(msg tea.Msg) *ResponseMsg {
query, ok := msg.(QueryMsg)
if !ok {
return nil
}
ids := m.At(query.X, query.Y)
return &ResponseMsg{IDs: ids}
}
}

1
go.mod
View file

@ -101,6 +101,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect

4
go.sum
View file

@ -150,6 +150,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
@ -214,6 +216,8 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/scylladb/go-set v1.0.2 h1:SkvlMCKhP0wyyct6j+0IHJkBkSZL+TDzZ4E7f7BCcRE=
github.com/scylladb/go-set v1.0.2/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=