mirror of
https://github.com/wagoodman/dive
synced 2026-03-14 22:35:50 +01:00
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:
parent
09a0115954
commit
76b8f2034f
21 changed files with 898 additions and 1072 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
12
cmd/dive/cli/internal/ui/v2/common/layer.go
Normal file
12
cmd/dive/cli/internal/ui/v2/common/layer.go
Normal 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
|
||||
}
|
||||
15
cmd/dive/cli/internal/ui/v2/common/layout.go
Normal file
15
cmd/dive/cli/internal/ui/v2/common/layout.go
Normal 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
|
||||
}
|
||||
15
cmd/dive/cli/internal/ui/v2/common/mouse.go
Normal file
15
cmd/dive/cli/internal/ui/v2/common/mouse.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
174
cmd/dive/cli/internal/ui/v2/panes/filetree/delegate.go
Normal file
174
cmd/dive/cli/internal/ui/v2/panes/filetree/delegate.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
34
cmd/dive/cli/internal/ui/v2/panes/filetree/items.go
Normal file
34
cmd/dive/cli/internal/ui/v2/panes/filetree/items.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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!")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
93
cmd/dive/cli/internal/ui/v2/zone/manager.go
Normal file
93
cmd/dive/cli/internal/ui/v2/zone/manager.go
Normal 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
1
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue