diff --git a/cmd/dive/cli/internal/ui/v2/app/messages.go b/cmd/dive/cli/internal/ui/v2/app/messages.go index eec2f1d..e0a43fd 100644 --- a/cmd/dive/cli/internal/ui/v2/app/messages.go +++ b/cmd/dive/cli/internal/ui/v2/app/messages.go @@ -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 diff --git a/cmd/dive/cli/internal/ui/v2/app/model.go b/cmd/dive/cli/internal/ui/v2/app/model.go index aa540fc..25023ba 100644 --- a/cmd/dive/cli/internal/ui/v2/app/model.go +++ b/cmd/dive/cli/internal/ui/v2/app/model.go @@ -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(), diff --git a/cmd/dive/cli/internal/ui/v2/common/layer.go b/cmd/dive/cli/internal/ui/v2/common/layer.go new file mode 100644 index 0000000..b557c13 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/common/layer.go @@ -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 +} diff --git a/cmd/dive/cli/internal/ui/v2/common/layout.go b/cmd/dive/cli/internal/ui/v2/common/layout.go new file mode 100644 index 0000000..554767b --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/common/layout.go @@ -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 +} diff --git a/cmd/dive/cli/internal/ui/v2/common/mouse.go b/cmd/dive/cli/internal/ui/v2/common/mouse.go new file mode 100644 index 0000000..409a97b --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/common/mouse.go @@ -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 +} diff --git a/cmd/dive/cli/internal/ui/v2/components/file_stats.go b/cmd/dive/cli/internal/ui/v2/components/file_stats.go index 554bc14..cd572e2 100644 --- a/cmd/dive/cli/internal/ui/v2/components/file_stats.go +++ b/cmd/dive/cli/internal/ui/v2/components/file_stats.go @@ -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") diff --git a/cmd/dive/cli/internal/ui/v2/panes/details/pane.go b/cmd/dive/cli/internal/ui/v2/panes/details/pane.go index 42b9960..57df327 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/details/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/details/pane.go @@ -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 } diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/delegate.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/delegate.go new file mode 100644 index 0000000..1e1947b --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/delegate.go @@ -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) +} 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 deleted file mode 100644 index c7262e3..0000000 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go +++ /dev/null @@ -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 -} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/items.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/items.go new file mode 100644 index 0000000..ebaf8a8 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/items.go @@ -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 +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/navigation.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/navigation.go deleted file mode 100644 index 19288bd..0000000 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/navigation.go +++ /dev/null @@ -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 -} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go index 66d4ecb..1057ea4 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go @@ -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, + ) } 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 c1550af..31cc73b 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go @@ -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 } diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/selection.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/selection.go deleted file mode 100644 index 3c54430..0000000 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/selection.go +++ /dev/null @@ -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 - } -} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/viewport_manager.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/viewport_manager.go deleted file mode 100644 index 0a01857..0000000 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/viewport_manager.go +++ /dev/null @@ -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) -} diff --git a/cmd/dive/cli/internal/ui/v2/panes/image/pane.go b/cmd/dive/cli/internal/ui/v2/panes/image/pane.go index 9594ec2..04fc2f3 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/image/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/image/pane.go @@ -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!") diff --git a/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go b/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go index 6dc1c9b..9bdd118 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go @@ -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 diff --git a/cmd/dive/cli/internal/ui/v2/styles/styles.go b/cmd/dive/cli/internal/ui/v2/styles/styles.go index 693b625..e9e0b74 100644 --- a/cmd/dive/cli/internal/ui/v2/styles/styles.go +++ b/cmd/dive/cli/internal/ui/v2/styles/styles.go @@ -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) diff --git a/cmd/dive/cli/internal/ui/v2/zone/manager.go b/cmd/dive/cli/internal/ui/v2/zone/manager.go new file mode 100644 index 0000000..90f5362 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/zone/manager.go @@ -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} + } +} diff --git a/go.mod b/go.mod index 429b79d..63762ff 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 6b2072a..ece409d 100644 --- a/go.sum +++ b/go.sum @@ -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=