Refactor file tree pane: modularize components and improve rendering

- Introduced Selection, ViewportManager, and Navigation components to manage state and interactions in the file tree pane.
- Removed the renderer.go file and integrated rendering logic directly into the pane.
- Enhanced the updateContent and renderTreeContent methods for better performance and clarity.
- Updated the View method to include a header and improved content layout using lipgloss.
- Added tree traversal utilities to manage visible nodes and their prefixes.
- Implemented file statistics tracking in the layers pane, displaying added, modified, and removed file counts.
- Updated styles for file icons and added new styles for file statistics.
- Improved mouse and keyboard interactions for better user experience.
This commit is contained in:
Aslan Dukaev 2026-01-13 12:11:02 +03:00
commit d1b1ec85f3
19 changed files with 1940 additions and 467 deletions

View file

@ -0,0 +1,200 @@
package app
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
v2styles "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"
)
// LayerDetailMsg is sent to show layer details
type LayerDetailMsg struct {
Layer *image.Layer
}
// LayerDetailModal manages the layer detail modal
type LayerDetailModal struct {
visible bool
layer *image.Layer
width int
height int
}
// NewLayerDetailModal creates a new layer detail modal
func NewLayerDetailModal() LayerDetailModal {
return LayerDetailModal{
visible: false,
layer: nil,
}
}
// Show makes the modal visible with the given layer
func (m *LayerDetailModal) Show(layer *image.Layer) {
m.visible = true
m.layer = layer
}
// Hide hides the modal
func (m *LayerDetailModal) Hide() {
m.visible = false
m.layer = nil
}
// IsVisible returns whether the modal is visible
func (m *LayerDetailModal) IsVisible() bool {
return m.visible
}
// Update handles messages for the modal
func (m LayerDetailModal) Update(msg tea.Msg) (LayerDetailModal, tea.Cmd) {
if !m.visible {
return m, nil
}
switch msg := msg.(type) {
case LayerDetailMsg:
m.Show(msg.Layer)
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "q", "esc", " ", "enter":
m.Hide()
return m, nil
}
}
return m, nil
}
// View renders the modal
func (m LayerDetailModal) View(screenWidth, screenHeight int) string {
if !m.visible || m.layer == nil {
return ""
}
// Calculate modal dimensions
modalWidth := min(screenWidth-10, 80)
modalHeight := min(screenHeight-10, 25)
// Create modal style
modalStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(v2styles.PrimaryColor).
Background(lipgloss.Color("#1C1C1E")).
Padding(1, 2).
Width(modalWidth).
Height(modalHeight)
// Build content
title := v2styles.LayerHeaderStyle.Render("📦 Layer Details")
content := m.buildContent(modalWidth - 4) // -4 for padding
help := v2styles.LayerValueStyle.Render("ESC/SPACE/ENTER: close")
fullContent := lipgloss.JoinVertical(lipgloss.Left,
title,
"",
content,
"",
help,
)
// Render modal
modal := modalStyle.Render(fullContent)
// Place modal in center
return lipgloss.Place(screenWidth, screenHeight, lipgloss.Center, lipgloss.Center, modal)
}
// buildContent creates the modal content
func (m LayerDetailModal) buildContent(width int) string {
if m.layer == nil {
return "No layer data available"
}
var lines []string
// Layer ID
lines = append(lines, m.renderField("ID", m.layer.Id, width))
// Digest
lines = append(lines, m.renderField("Digest", m.layer.Digest, width))
// Index
lines = append(lines, m.renderField("Index", fmt.Sprintf("%d", m.layer.Index), width))
// Size
size := utils.FormatSize(m.layer.Size)
lines = append(lines, m.renderField("Size", fmt.Sprintf("%s (%d bytes)", size, m.layer.Size), width))
// Human-readable size
humanSize := humanize.Bytes(m.layer.Size)
lines = append(lines, m.renderField("Human Size", humanSize, width))
// Names
if len(m.layer.Names) > 0 {
names := strings.Join(m.layer.Names, ", ")
lines = append(lines, m.renderField("Names", names, width))
}
// Command (multiline)
lines = append(lines, "")
lines = append(lines, v2styles.LayerHeaderStyle.Render("Command:"))
cmdLines := m.wrapText(m.layer.Command, width)
for _, line := range cmdLines {
lines = append(lines, v2styles.LayerValueStyle.Render(line))
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
// renderField renders a key-value pair
func (m LayerDetailModal) renderField(key, value string, width int) string {
label := v2styles.LayerHeaderStyle.Render(key + ":")
return lipgloss.JoinHorizontal(lipgloss.Top, label, " ", v2styles.LayerValueStyle.Render(value))
}
// wrapText wraps text to fit width
func (m LayerDetailModal) wrapText(text string, width int) []string {
if text == "" {
return []string{"(none)"}
}
words := strings.Fields(text)
if len(words) == 0 {
return []string{"(none)"}
}
var lines []string
currentLine := ""
for _, word := range words {
testLine := currentLine
if testLine == "" {
testLine = word
} else {
testLine += " " + word
}
if len(testLine) <= width {
currentLine = testLine
} else {
if currentLine != "" {
lines = append(lines, currentLine)
}
currentLine = word
}
}
if currentLine != "" {
lines = append(lines, currentLine)
}
return lines
}

View file

@ -12,11 +12,12 @@ 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/keys"
filetree "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/filetree"
filetreepane "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/filetree"
imagepane "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/image"
"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"
filetree "github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/dive/image"
)
@ -85,7 +86,7 @@ type Model struct {
layersPane layers.Pane
detailsPane details.Pane
imagePane imagepane.Pane
treePane filetree.Pane
treePane filetreepane.Pane
// Active pane state
activePane Pane
@ -93,6 +94,9 @@ type Model struct {
// Filter state
filter FilterModel
// Layer detail modal
layerDetailModal LayerDetailModal
// Help and key bindings
keys keys.KeyMap
help help.Model
@ -111,12 +115,15 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
// Initialize filetree viewmodel
var treeVM *viewmodel.FileTreeViewModel
var comparer filetree.Comparer
if len(analysis.RefTrees) > 0 {
v1cfg := v1.Config{
Analysis: analysis,
Content: content,
Preferences: prefs,
}
// Get comparer for layer tree comparison
comparer, _ = v1cfg.TreeComparer()
// Note: we ignore the error here since treeVM.Update() will be called in Init()
treeVM, _ = viewmodel.NewFileTreeViewModel(v1cfg, 0)
}
@ -128,35 +135,37 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
h.Styles.Ellipsis = styles.StatusStyle
f := NewFilterModel()
layerDetailModal := NewLayerDetailModal()
// Create pane components
layersPane := layers.New(layerVM)
layersPane := layers.New(layerVM, comparer)
detailsPane := details.New()
imagePane := imagepane.New(&analysis)
treePane := filetree.New(treeVM)
treePane := filetreepane.New(treeVM)
// Set initial focus
layersPane.Focus()
// Create model with initial dimensions
model := Model{
analysis: analysis,
content: content,
prefs: prefs,
ctx: ctx,
layerVM: layerVM,
treeVM: treeVM,
layersPane: layersPane,
detailsPane: detailsPane,
imagePane: imagePane,
treePane: treePane,
width: 80,
height: 24,
quitting: false,
activePane: PaneLayer,
keys: keys.Keys,
help: h,
filter: f,
analysis: analysis,
content: content,
prefs: prefs,
ctx: ctx,
layerVM: layerVM,
treeVM: treeVM,
layersPane: layersPane,
detailsPane: detailsPane,
imagePane: imagePane,
treePane: treePane,
width: 80,
height: 24,
quitting: false,
activePane: PaneLayer,
keys: keys.Keys,
help: h,
filter: f,
layerDetailModal: layerDetailModal,
}
// CRITICAL: Calculate initial layout and set pane sizes immediately
@ -213,6 +222,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// If layer detail modal is visible, let it handle keys first
if m.layerDetailModal.IsVisible() {
var cmd tea.Cmd
m.layerDetailModal, cmd = m.layerDetailModal.Update(msg)
cmds = append(cmds, cmd)
break
}
// If filter is visible, let filter handle keys
if m.filter.IsVisible() {
var cmd tea.Cmd
@ -238,7 +255,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case PaneTree:
newPane, cmd := m.treePane.Update(msg)
m.treePane = newPane.(filetree.Pane)
m.treePane = newPane.(filetreepane.Pane)
cmds = append(cmds, cmd)
}
@ -268,14 +285,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.imagePane.Blur()
m.treePane.Blur()
case filetree.NodeToggledMsg:
case filetreepane.NodeToggledMsg:
// Tree node was toggled - tree pane already updated its content
// Nothing to do here
case filetree.RefreshTreeContentMsg:
case filetreepane.RefreshTreeContentMsg:
// Request to refresh tree content
m.treePane.SetTreeVM(m.treeVM)
case layers.ShowLayerDetailMsg:
// Show layer detail modal
m.layerDetailModal.Show(msg.Layer)
case tea.MouseMsg:
// Route mouse events to appropriate pane
x, y := msg.X, msg.Y
@ -316,8 +337,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
} else if inRightCol {
// Tree pane
newPane, cmd := m.treePane.Update(msg)
m.treePane = newPane.(filetree.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
newPane, cmd := m.treePane.Update(localMsg)
m.treePane = newPane.(filetreepane.Pane)
cmds = append(cmds, cmd)
if m.activePane != PaneTree {
m.activePane = PaneTree
@ -426,6 +453,12 @@ func (m Model) View() string {
statusBar,
)
// Overlay layer detail modal if visible
if m.layerDetailModal.IsVisible() {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
m.layerDetailModal.View(m.width, m.height))
}
// Overlay filter modal if visible
if m.filter.IsVisible() {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,

View file

@ -0,0 +1,240 @@
package components
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils"
)
// Re-export FileStats from utils package
type FileStats = utils.FileStats
// StatsPartType represents which part of the stats this is
type StatsPartType int
const (
StatsPartAdded StatsPartType = iota
StatsPartModified
StatsPartRemoved
)
// StatsPartRenderer handles rendering a single part of file statistics with interactive state
type StatsPartRenderer struct {
partType StatsPartType
value int
active bool
}
// NewStatsPartRenderer creates a new stats part renderer
func NewStatsPartRenderer(partType StatsPartType, value int) StatsPartRenderer {
return StatsPartRenderer{
partType: partType,
value: value,
active: false,
}
}
// SetValue updates the value
func (r *StatsPartRenderer) SetValue(value int) {
r.value = value
}
// GetValue returns the current value
func (r *StatsPartRenderer) GetValue() int {
return r.value
}
// SetActive sets the active state
func (r *StatsPartRenderer) SetActive(active bool) {
r.active = active
}
// IsActive returns whether the component is active
func (r *StatsPartRenderer) IsActive() bool {
return r.active
}
// ToggleActive toggles the active state
func (r *StatsPartRenderer) ToggleActive() {
r.active = !r.active
}
// GetType returns the type of this stats part
func (r *StatsPartRenderer) GetType() StatsPartType {
return r.partType
}
// 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 = "-"
}
text := fmt.Sprintf("%s%d", prefix, r.value)
if r.active {
return r.activeStyle().Render(text)
}
return r.defaultStyle().Render(text)
}
// defaultStyle returns the style for inactive state
func (r *StatsPartRenderer) defaultStyle() lipgloss.Style {
switch r.partType {
case StatsPartAdded:
return styles.FileStatsAddedStyle
case StatsPartModified:
return styles.FileStatsModifiedStyle
case StatsPartRemoved:
return styles.FileStatsRemovedStyle
default:
return lipgloss.NewStyle()
}
}
// activeStyle returns the style for active state
func (r *StatsPartRenderer) activeStyle() lipgloss.Style {
var bgColor lipgloss.Color
switch r.partType {
case StatsPartAdded:
bgColor = styles.SuccessColor
case StatsPartModified:
bgColor = styles.WarningColor
case StatsPartRemoved:
bgColor = styles.ErrorColor
default:
bgColor = styles.PrimaryColor
}
// FIX: Removed Padding(0, 1) to prevent text shifting
// The background color is enough indication of state
return lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(bgColor).
Bold(true)
}
// GetVisualWidth returns the visual width of the rendered part (without ANSI codes)
func (r *StatsPartRenderer) GetVisualWidth() int {
// Format: "+N" or "~N" or "-N" where N is the value
return 1 + len(fmt.Sprintf("%d", r.value))
}
// FileStatsRow manages a row with three stats parts
type FileStatsRow struct {
added StatsPartRenderer
modified StatsPartRenderer
removed StatsPartRenderer
}
// NewFileStatsRow creates a new file stats row
func NewFileStatsRow() FileStatsRow {
return FileStatsRow{
added: NewStatsPartRenderer(StatsPartAdded, 0),
modified: NewStatsPartRenderer(StatsPartModified, 0),
removed: NewStatsPartRenderer(StatsPartRemoved, 0),
}
}
// SetStats updates all statistics
func (r *FileStatsRow) SetStats(stats FileStats) {
r.added.SetValue(stats.Added)
r.modified.SetValue(stats.Modified)
r.removed.SetValue(stats.Removed)
}
// GetStats returns the current statistics
func (r *FileStatsRow) GetStats() FileStats {
return FileStats{
Added: r.added.GetValue(),
Modified: r.modified.GetValue(),
Removed: r.removed.GetValue(),
}
}
// GetPart returns the specific part renderer
func (r *FileStatsRow) GetPart(partType StatsPartType) *StatsPartRenderer {
switch partType {
case StatsPartAdded:
return &r.added
case StatsPartModified:
return &r.modified
case StatsPartRemoved:
return &r.removed
default:
return nil
}
}
// GetAdded returns the added part renderer
func (r *FileStatsRow) GetAdded() *StatsPartRenderer {
return &r.added
}
// GetModified returns the modified part renderer
func (r *FileStatsRow) GetModified() *StatsPartRenderer {
return &r.modified
}
// GetRemoved returns the removed part renderer
func (r *FileStatsRow) GetRemoved() *StatsPartRenderer {
return &r.removed
}
// DeactivateAll deactivates all parts
func (r *FileStatsRow) DeactivateAll() {
r.added.SetActive(false)
r.modified.SetActive(false)
r.removed.SetActive(false)
}
// Render renders the complete stats row as a string
func (r *FileStatsRow) Render() string {
// Join with spaces
addedStr := r.added.Render()
modifiedStr := r.modified.Render()
removedStr := r.removed.Render()
return fmt.Sprintf("%s %s %s", addedStr, modifiedStr, removedStr)
}
// GetPartPositions returns the X positions and widths of each part
// Returns: (addedX, addedWidth, modifiedX, modifiedWidth, removedX, removedWidth)
func (r *FileStatsRow) GetPartPositions(startX int) (int, int, int, int, int, int) {
addedWidth := r.added.GetVisualWidth()
modifiedWidth := r.modified.GetVisualWidth()
removedWidth := r.removed.GetVisualWidth()
addedX := startX
modifiedX := addedX + addedWidth + 1 // +1 for space
removedX := modifiedX + modifiedWidth + 1 // +1 for space
return addedX, addedWidth, modifiedX, modifiedWidth, removedX, removedWidth
}
// GetPartAtPosition returns which part is at the given X position
// Returns (partType, found)
func (r *FileStatsRow) GetPartAtPosition(x int, startX int) (StatsPartType, bool) {
addedX, addedWidth, modifiedX, modifiedWidth, removedX, removedWidth := r.GetPartPositions(startX)
if x >= addedX && x < addedX+addedWidth {
return StatsPartAdded, true
}
if x >= modifiedX && x < modifiedX+modifiedWidth {
return StatsPartModified, true
}
if x >= removedX && x < removedX+removedWidth {
return StatsPartRemoved, true
}
return -1, false
}

View file

@ -0,0 +1,46 @@
// Package filetree provides a tree view component for displaying Docker image file hierarchies.
//
// The package is organized into focused components that work together:
//
// Core Components:
// - Pane: Main coordinator implementing tea.Model interface
// - Selection: Manages tree index selection state
// - ViewportManager: Wrapper around bubbletea viewport
//
// Rendering Components:
// - HeaderRenderer: Renders table header row
// - NodeRenderer: Renders individual tree nodes
// - MetadataFormatter: Formats file metadata (permissions, uid:gid, size)
// - TreeTraversal: Collects visible nodes and handles tree traversal
//
// Logic Components:
// - Navigation: Handles navigation movements (up/down/pageup/pagedown)
// - InputHandler: Processes keyboard and mouse input
//
// Dependency Graph:
//
// pane.go (coordinator)
// ├── input_handler.go
// │ ├── navigation.go
// │ │ ├── selection.go
// │ │ └── viewport_manager.go
// │ └── selection.go
// ├── node_renderer.go
// │ ├── metadata_formatter.go
// │ └── styles (external)
// ├── header_renderer.go
// │ └── styles (external)
// └── tree_traversal.go (pure functions)
//
// Usage:
//
// treeVM := // ... obtain FileTreeViewModel
// pane := filetree.New(treeVM)
// pane.SetSize(width, height)
// pane.Focus()
//
// Messages:
// - NodeToggledMsg: Sent when a tree node is collapsed/expanded
// - TreeSelectionChangedMsg: Sent when a tree node is selected
// - RefreshTreeContentMsg: Requests tree content to be refreshed
package filetree

View file

@ -0,0 +1,71 @@
package filetree
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
)
// RenderHeader creates a column header row with FIXED WIDTH columns
func RenderHeader(width int) string {
// Header style (muted to not distract)
headerColor := styles.DarkGrayColor
// Create cell styles with FIXED WIDTH (same as renderer.go!)
sizeHeaderCell := lipgloss.NewStyle().
Width(SizeWidth).
Align(lipgloss.Right).
Foreground(headerColor)
uidGidHeaderCell := lipgloss.NewStyle().
Width(UidGidWidth).
Align(lipgloss.Right).
Foreground(headerColor)
permHeaderCell := lipgloss.NewStyle().
Width(PermWidth).
Align(lipgloss.Right).
Foreground(headerColor)
// Render each header cell with fixed width
styledSizeHeader := sizeHeaderCell.Render("Size")
styledUidGidHeader := uidGidHeaderCell.Render("UID:GID")
styledPermHeader := permHeaderCell.Render("Permissions")
// Join cells horizontally with gap (same as renderer.go!)
metaBlock := lipgloss.JoinHorizontal(
lipgloss.Top,
styledSizeHeader,
lipgloss.NewStyle().Width(len(MetaGap)).Render(MetaGap),
styledUidGidHeader,
lipgloss.NewStyle().Width(len(MetaGap)).Render(MetaGap),
styledPermHeader,
)
// CRITICAL: Must match viewport width (width - 2)!
// Viewport is created with width-2, so header must use the same width
availableWidth := width - 2
if availableWidth < 10 {
availableWidth = 10
}
// Left part: "Name" label (with muted color like other headers)
nameHeaderStyle := lipgloss.NewStyle().Foreground(headerColor)
styledNameHeader := nameHeaderStyle.Render("Name")
// Get actual metadata block width
metaBlockWidth := lipgloss.Width(metaBlock)
// Calculate padding to push metadata to the right
padding := availableWidth - runewidth.StringWidth("Name") - metaBlockWidth
if padding < 1 {
padding = 1
}
// Assemble: Name + padding + right-aligned metadata
fullText := styledNameHeader + strings.Repeat(" ", padding) + metaBlock
return fullText
}

View file

@ -0,0 +1,165 @@
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
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
}
// 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 {
if h.treeVM == nil || h.treeVM.ViewTree == nil {
return nil
}
visibleNodes := CollectVisibleNodes(h.treeVM.ViewTree.Root)
treeIndex := h.selection.GetTreeIndex()
if treeIndex >= len(visibleNodes) {
h.selection.MoveToIndex(len(visibleNodes) - 1)
treeIndex = h.selection.GetTreeIndex()
}
if treeIndex < 0 {
h.selection.SetTreeIndex(0)
treeIndex = h.selection.GetTreeIndex()
}
if treeIndex < len(visibleNodes) {
selectedNode := visibleNodes[treeIndex].Node
if selectedNode.Data.FileInfo.IsDir() {
// Toggle the collapsed flag directly on the node
selectedNode.Data.ViewInfo.Collapsed = !selectedNode.Data.ViewInfo.Collapsed
// Just refresh the UI - don't call treeVM.Update() as it rebuilds the tree
h.navigation.Refresh()
return func() tea.Msg {
return NodeToggledMsg{NodeIndex: treeIndex}
}
}
}
return nil
}

View file

@ -0,0 +1,99 @@
package filetree
// Column width constants for metadata display
const (
PermWidth = 11 // "-rwxr-xr-x"
UidGidWidth = 9 // "0:0" or "1000:1000"
SizeWidth = 8 // right-aligned size
MetaGap = " "
)
// FormatPermissions converts os.FileMode to Unix permission string (e.g. "-rwxr-xr-x")
func FormatPermissions(mode interface{}) string {
var m uint32
switch v := mode.(type) {
case uint32:
m = v
case int:
m = uint32(v)
default:
return "----------"
}
// Convert to string representation
perms := []rune("----------")
// File type
if m&(1<<15) != 0 { // regular file
perms[0] = '-'
} else if m&(1<<14) != 0 { // directory
perms[0] = 'd'
} else if m&(1<<12) != 0 { // symbolic link
perms[0] = 'l'
}
// Owner permissions
if m&(1<<8) != 0 {
perms[1] = 'r'
}
if m&(1<<7) != 0 {
perms[2] = 'w'
}
if m&(1<<6) != 0 {
perms[3] = 'x'
} else if m&(1<<11) != 0 { // setuid
perms[3] = 'S'
}
// Group permissions
if m&(1<<5) != 0 {
perms[4] = 'r'
}
if m&(1<<4) != 0 {
perms[5] = 'w'
}
if m&(1<<3) != 0 {
perms[6] = 'x'
} else if m&(1<<10) != 0 { // setgid
perms[6] = 'S'
}
// Other permissions
if m&(1<<2) != 0 {
perms[7] = 'r'
}
if m&(1<<1) != 0 {
perms[8] = 'w'
}
if m&(1<<0) != 0 {
perms[9] = 'x'
} else if m&(1<<9) != 0 { // sticky bit
perms[9] = 'T'
}
return string(perms)
}
// FormatUidGid formats UID:GID for display, showing "-" for default root:root (0:0)
func FormatUidGid(uid, gid uint32) string {
if uid != 0 || gid != 0 {
return formatUint32(uid) + ":" + formatUint32(gid)
}
return "-"
}
// formatUint32 formats a uint32 to string
func formatUint32(v uint32) string {
var buf [20]byte
i := len(buf)
n := int64(v)
for n > 0 {
i--
buf[i] = '0' + byte(n%10)
n /= 10
}
if i == len(buf) {
return "0"
}
return string(buf[i:])
}

View file

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

View file

@ -0,0 +1,211 @@
package filetree
import (
"strings"
"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"
)
// RenderNodeWithCursor renders a node with tree guides and improved visual design
func RenderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, prefix string, isSelected bool, width int) {
if node == nil {
return
}
// 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
}
// 2. Diff status (color only, no icons)
switch node.Data.DiffType {
case filetree.Added:
color = styles.DiffAddedColor
case filetree.Removed:
color = styles.DiffRemovedColor
case filetree.Modified:
color = styles.DiffModifiedColor
}
// 3. Format metadata (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)
} else {
uidGid = "-"
}
// Size (empty for folders)
var sizeStr string
if !node.Data.FileInfo.IsDir() {
sizeStr = utils.FormatSize(uint64(node.Data.FileInfo.Size))
}
// Format name
name := node.Name
if name == "" {
name = "/"
}
if node.Data.FileInfo.TypeFlag == 16 && node.Data.FileInfo.Linkname != "" {
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 := ""
if isSelected {
cursorMark = ""
}
// Render tree guides (lines should be gray)
// If selected, apply background to tree guides too
styledPrefix := styles.TreeGuideStyle.Render(prefix)
if isSelected {
styledPrefix = lipgloss.NewStyle().
Foreground(styles.DarkGrayColor).
Background(lipgloss.Color("#1C1C1E")).
Render(prefix)
}
// 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
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)
uidGidCell := lipgloss.NewStyle().
Width(UidGidWidth).
Align(lipgloss.Right).
Foreground(metaColor).
Background(metaBg)
permCell := lipgloss.NewStyle().
Width(PermWidth).
Align(lipgloss.Right).
Foreground(metaColor).
Background(metaBg)
// Render each cell with fixed width
styledSize := sizeCell.Render(sizeStr)
styledUidGid := uidGidCell.Render(uidGid)
styledPerm := permCell.Render(perm)
// 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
metaBlock := lipgloss.JoinHorizontal(
lipgloss.Top,
styledSize,
styledGap,
styledUidGid,
styledGap,
styledPerm,
)
// 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)
metaBlockWidth := lipgloss.Width(metaBlock)
// Available width for filename (between file and right-aligned metadata)
availableForName := width - fixedPartWidth - metaBlockWidth - 2 // -2 for gaps
if availableForName < 5 {
availableForName = 5
}
// Truncate name if needed
displayName := name
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
if paddingNeeded < 1 {
paddingNeeded = 1 // At least 1 space gap
}
// If selected, padding should also have background
paddingStyle := lipgloss.NewStyle()
if isSelected {
paddingStyle = paddingStyle.Background(lipgloss.Color("#1C1C1E"))
}
padding := paddingStyle.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
}

View file

@ -5,6 +5,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/viewport"
"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/styles"
@ -31,20 +32,44 @@ type Pane struct {
width int
height int
treeVM *viewmodel.FileTreeViewModel
viewport viewport.Model
treeIndex int
// Components
selection *Selection
viewportMgr *ViewportManager
navigation *Navigation
inputHandler *InputHandler
}
// New creates a new tree pane
func New(treeVM *viewmodel.FileTreeViewModel) Pane {
vp := viewport.New(80, 20)
// Initialize components
selection := NewSelection()
viewportMgr := NewViewportManager(80, 20)
navigation := NewNavigation(selection, viewportMgr)
inputHandler := NewInputHandler(navigation, selection, viewportMgr, treeVM)
p := Pane{
treeVM: treeVM,
viewport: vp,
treeIndex: 0,
width: 80,
height: 20,
treeVM: treeVM,
selection: selection,
viewportMgr: viewportMgr,
navigation: navigation,
inputHandler: inputHandler,
focused: false,
width: 80,
height: 20,
}
// Set up callbacks
p.navigation.SetVisibleNodesFunc(func() []VisibleNode {
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)
// IMPORTANT: Generate content immediately so viewport is not empty on startup
p.updateContent()
return p
@ -54,6 +79,7 @@ func New(treeVM *viewmodel.FileTreeViewModel) Pane {
func (m *Pane) SetSize(width, height int) {
m.width = width
m.height = height
m.inputHandler.SetSize(width, height)
viewportWidth := width - 2
viewportHeight := height - layout.BoxContentPadding
@ -61,8 +87,7 @@ func (m *Pane) SetSize(width, height int) {
viewportHeight = 0
}
m.viewport.Width = viewportWidth
m.viewport.Height = viewportHeight
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
@ -72,30 +97,32 @@ func (m *Pane) SetSize(width, height int) {
// SetTreeVM updates the tree viewmodel
func (m *Pane) SetTreeVM(treeVM *viewmodel.FileTreeViewModel) {
m.treeVM = treeVM
m.treeIndex = 0
m.viewport.GotoTop()
m.selection.SetTreeIndex(0)
m.viewportMgr.GotoTop()
m.updateContent()
}
// SetTreeIndex sets the current tree index
func (m *Pane) SetTreeIndex(index int) {
m.treeIndex = index
m.syncScroll()
m.selection.SetTreeIndex(index)
m.navigation.SyncScroll()
}
// GetTreeIndex returns the current tree index
func (m *Pane) GetTreeIndex() int {
return m.treeIndex
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
@ -115,40 +142,39 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if !m.focused {
return m, nil
}
switch msg.String() {
case "up", "k":
cmds = append(cmds, m.moveUp())
case "down", "j":
cmds = append(cmds, m.moveDown())
case "enter", " ":
cmds = append(cmds, m.toggleCollapse())
cmds, consumed := m.inputHandler.HandleKeyPress(msg)
if consumed {
// Don't pass to viewport
return m, tea.Batch(cmds...)
}
case tea.MouseMsg:
if msg.Action == tea.MouseActionPress {
var keyCmds []tea.Cmd
if msg.Button == tea.MouseButtonWheelUp {
cmds = append(cmds, m.moveUp())
keyCmds = append(keyCmds, m.navigation.MoveUp())
} else if msg.Button == tea.MouseButtonWheelDown {
cmds = append(cmds, m.moveDown())
keyCmds = append(keyCmds, m.navigation.MoveDown())
}
if msg.Button == tea.MouseButtonLeft {
if cmd := m.handleClick(msg.X, msg.Y); cmd != nil {
cmds = append(cmds, cmd)
if cmd := m.inputHandler.HandleMouseClick(msg); cmd != nil {
keyCmds = append(keyCmds, cmd)
}
}
if len(keyCmds) > 0 {
return m, tea.Batch(keyCmds...)
}
}
case RefreshTreeContentMsg:
m.updateContent()
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
// Always update viewport
_, cmd := m.viewportMgr.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
@ -156,134 +182,22 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View renders the pane
func (m Pane) View() string {
content := m.viewport.View()
return styles.RenderBox("Current Layer Contents", m.width, m.height, content, m.focused)
}
// 1. Generate static header
header := RenderHeader(m.width)
// moveUp moves selection up
func (m *Pane) moveUp() tea.Cmd {
if m.treeIndex > 0 {
m.treeIndex--
m.syncScroll()
}
return nil
}
// 2. Get viewport content
content := m.viewportMgr.GetViewport().View()
// moveDown moves selection down
func (m *Pane) moveDown() tea.Cmd {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
return nil
}
// 3. Combine: Header + Content
fullContent := lipgloss.JoinVertical(lipgloss.Left, header, content)
visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root)
if m.treeIndex < len(visibleNodes)-1 {
m.treeIndex++
m.syncScroll()
}
return nil
}
// toggleCollapse toggles the current node's collapse state
func (m *Pane) toggleCollapse() tea.Cmd {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
return nil
}
visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root)
if m.treeIndex >= len(visibleNodes) {
m.treeIndex = len(visibleNodes) - 1
}
if m.treeIndex < 0 {
m.treeIndex = 0
}
if m.treeIndex < len(visibleNodes) {
selectedNode := visibleNodes[m.treeIndex].Node
if selectedNode.Data.FileInfo.IsDir() {
selectedNode.Data.ViewInfo.Collapsed = !selectedNode.Data.ViewInfo.Collapsed
_ = m.treeVM.Update(nil, m.width, m.height)
m.updateContent()
return func() tea.Msg {
return NodeToggledMsg{NodeIndex: m.treeIndex}
}
}
}
return nil
}
// handleClick processes a mouse click
func (m *Pane) handleClick(x, y int) tea.Cmd {
if x < 0 || x >= m.width || y < 0 {
return nil
}
relativeY := y - layout.ContentVisualOffset
if relativeY < 0 || relativeY >= m.viewport.Height {
return nil
}
if m.treeVM == nil || m.treeVM.ViewTree == nil {
return nil
}
visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root)
targetIndex := relativeY + m.viewport.YOffset
if targetIndex >= 0 && targetIndex < len(visibleNodes) {
if m.treeIndex == targetIndex {
return m.toggleCollapse()
} else {
m.treeIndex = targetIndex
m.syncScroll()
return func() tea.Msg {
return TreeSelectionChangedMsg{NodeIndex: m.treeIndex}
}
}
}
return nil
}
// syncScroll ensures the cursor is always visible
func (m *Pane) syncScroll() {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
return
}
visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root)
if len(visibleNodes) == 0 {
return
}
if m.treeIndex >= len(visibleNodes) {
m.treeIndex = len(visibleNodes) - 1
}
if m.treeIndex < 0 {
m.treeIndex = 0
}
visibleHeight := m.viewport.Height
if visibleHeight <= 0 {
return
}
if m.treeIndex < m.viewport.YOffset {
m.viewport.SetYOffset(m.treeIndex)
}
if m.treeIndex >= m.viewport.YOffset+visibleHeight {
m.viewport.SetYOffset(m.treeIndex - visibleHeight + 1)
}
return styles.RenderBox("Current Layer Contents", m.width, m.height, fullContent, m.focused)
}
// updateContent regenerates the viewport content
func (m *Pane) updateContent() {
if m.treeVM == nil {
m.viewport.SetContent("No tree data")
m.viewportMgr.SetContent("No tree data")
return
}
@ -291,7 +205,7 @@ func (m *Pane) updateContent() {
if content == "" {
content = "(File tree rendering in progress...)"
}
m.viewport.SetContent(content)
m.viewportMgr.SetContent(content)
}
// renderTreeContent generates the tree content
@ -301,17 +215,55 @@ func (m *Pane) renderTreeContent() string {
}
var sb strings.Builder
visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root)
visibleNodes := CollectVisibleNodes(m.treeVM.ViewTree.Root)
viewportWidth := m.viewportMgr.GetViewport().Width
for i, vn := range visibleNodes {
isSelected := (i == m.treeIndex)
renderNodeWithCursor(&sb, vn.Node, vn.Depth, isSelected, m.viewport.Width)
isSelected := (i == m.selection.GetTreeIndex())
RenderNodeWithCursor(&sb, vn.Node, vn.Prefix, isSelected, viewportWidth)
}
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 {
return nil
}
visibleNodes := CollectVisibleNodes(m.treeVM.ViewTree.Root)
treeIndex := m.selection.GetTreeIndex()
if treeIndex >= len(visibleNodes) {
m.selection.MoveToIndex(len(visibleNodes) - 1)
treeIndex = m.selection.GetTreeIndex()
}
if treeIndex < 0 {
m.selection.SetTreeIndex(0)
treeIndex = m.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
m.updateContent()
return func() tea.Msg {
return NodeToggledMsg{NodeIndex: treeIndex}
}
}
}
return nil
}
// GetViewport returns the underlying viewport
func (m *Pane) GetViewport() *viewport.Model {
return &m.viewport
return m.viewportMgr.GetViewport()
}

View file

@ -1,257 +0,0 @@
package filetree
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/dive/filetree"
)
// VisibleNode represents a node with its depth for rendering
type VisibleNode struct {
Node *filetree.FileNode
Depth int
}
// collectVisibleNodes collects all visible nodes in a flat list
func collectVisibleNodes(root *filetree.FileNode) []VisibleNode {
var nodes []VisibleNode
var traverse func(*filetree.FileNode, int)
traverse = func(node *filetree.FileNode, depth int) {
if node == nil {
return
}
// Skip root node itself, start from children
if node.Parent != nil {
nodes = append(nodes, VisibleNode{Node: node, Depth: depth})
}
// Recurse into children if directory and not collapsed
if node.Data.FileInfo.IsDir() && !node.Data.ViewInfo.Collapsed {
sortedChildren := sortChildren(node.Children)
for _, child := range sortedChildren {
traverse(child, depth+1)
}
}
}
// Start from root's children if root is not collapsed
if !root.Data.ViewInfo.Collapsed {
sortedChildren := sortChildren(root.Children)
for _, child := range sortedChildren {
traverse(child, 0)
}
}
return nodes
}
// renderNodeWithCursor renders a node with optional cursor indicator
func renderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, depth int, isSelected bool, width int) {
if node == nil {
return
}
// 1. Cursor indicator
var cursor string
if isSelected {
cursor = "▸ "
} else {
cursor = " "
}
// 2. Icon and color
icon := styles.IconFile
diffIcon := ""
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
}
// 3. Diff status
switch node.Data.DiffType {
case filetree.Added:
color = styles.DiffAddedColor
diffIcon = styles.IconAdded
case filetree.Removed:
color = styles.DiffRemovedColor
diffIcon = styles.IconRemoved
case filetree.Modified:
color = styles.DiffModifiedColor
diffIcon = styles.IconModified
}
// 4. Format name
name := node.Name
if name == "" {
name = "/"
}
// Symlinks
if node.Data.FileInfo.TypeFlag == 16 && node.Data.FileInfo.Linkname != "" {
name += " → " + node.Data.FileInfo.Linkname
}
// 5. Indent
indent := strings.Repeat(" ", depth)
// 6. Build line (without styles yet)
// Add space after diffIcon if present
if diffIcon != "" {
diffIcon += " "
}
rawText := fmt.Sprintf("%s%s%s%s", indent+cursor, diffIcon, icon, name)
// ВАЖНО: Truncate to prevent line wrapping which breaks scroll alignment
// CRITICAL: Leave 1 char margin for terminal cursor to prevent auto-scroll
maxTextWidth := width - 1
if maxTextWidth < 10 {
maxTextWidth = 10 // Protection
}
truncatedText := runewidth.Truncate(rawText, maxTextWidth, "…")
// 7. Apply style
style := lipgloss.NewStyle().Foreground(color)
if isSelected {
// For selected items, fill background to full width BUT don't add padding
// Using MaxWidth instead of Width to prevent adding extra whitespace
style = style.
Background(styles.PrimaryColor).
Foreground(lipgloss.Color("#000000")).
Bold(true).
MaxWidth(width) // Prevent exceeding width, but don't add padding
}
sb.WriteString(style.Render(truncatedText))
sb.WriteString("\n")
// Note: no recursion here since we're using collectVisibleNodes instead
}
// renderNode recursively renders a tree node with icons and colors
func renderNode(sb *strings.Builder, node *filetree.FileNode, depth int, prefix string) {
if node == nil {
return
}
// Don't render root element (it's usually empty)
if node.Parent == nil {
// Render root's children
if !node.Data.ViewInfo.Collapsed {
sortedChildren := sortChildren(node.Children)
for _, child := range sortedChildren {
renderNode(sb, child, depth, "")
}
}
return
}
// 1. Determine icon
icon := styles.IconFile
diffIcon := ""
// Determine file type
if node.Data.FileInfo.IsDir() {
if node.Data.ViewInfo.Collapsed {
icon = styles.IconDirClosed
} else {
icon = styles.IconDirOpen
}
} else if node.Data.FileInfo.TypeFlag == 16 { // tar.TypeSymlink
icon = styles.IconSymlink
}
// Determine Diff (Added/Removed/Modified)
color := styles.DiffNormalColor
switch node.Data.DiffType {
case filetree.Added:
color = styles.DiffAddedColor
diffIcon = styles.IconAdded
case filetree.Removed:
color = styles.DiffRemovedColor
diffIcon = styles.IconRemoved
case filetree.Modified:
color = styles.DiffModifiedColor
diffIcon = styles.IconModified
}
// 2. Build line
name := node.Name
if name == "" {
name = "/"
}
// Add symlink target if present
if node.Data.FileInfo.TypeFlag == 16 && node.Data.FileInfo.Linkname != "" {
name += " → " + node.Data.FileInfo.Linkname
}
// Build line with prefix (indent)
line := prefix + diffIcon + " " + icon + " " + name
// Apply color
style := lipgloss.NewStyle().Foreground(color)
sb.WriteString(style.Render(line))
sb.WriteString("\n")
// 3. Recursion for children (if folder not collapsed)
if node.Data.FileInfo.IsDir() && !node.Data.ViewInfo.Collapsed && !node.IsLeaf() {
// Calculate prefix for children
childPrefix := prefix + " "
// Sort and render children
sortedChildren := sortChildren(node.Children)
for _, child := range sortedChildren {
renderNode(sb, child, depth+1, childPrefix)
}
}
}
// sortChildren sorts node children: directories first, then files, all alphabetically
func sortChildren(children map[string]*filetree.FileNode) []*filetree.FileNode {
if children == nil {
return nil
}
// Split into directories and files
var dirs []*filetree.FileNode
var files []*filetree.FileNode
for _, child := range children {
if child.Data.FileInfo.IsDir() {
dirs = append(dirs, child)
} else {
files = append(files, child)
}
}
// Sort directories
sort.Slice(dirs, func(i, j int) bool {
return dirs[i].Name < dirs[j].Name
})
// Sort files
sort.Slice(files, func(i, j int) bool {
return files[i].Name < files[j].Name
})
// Combine: directories first, then files
result := append(dirs, files...)
return result
}

View file

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

View file

@ -0,0 +1,145 @@
package filetree
import (
"sort"
"strings"
"github.com/wagoodman/dive/dive/filetree"
)
// VisibleNode represents a node with its tree prefix for rendering
type VisibleNode struct {
Node *filetree.FileNode
Prefix string // Tree guide prefix, e.g. "│ ├── ", "└── "
}
// CollectVisibleNodes collects all visible nodes with tree guide prefixes
func CollectVisibleNodes(root *filetree.FileNode) []VisibleNode {
var nodes []VisibleNode
// levels tracks state for each nesting level:
// true = this level is the last child (use spaces)
// false = more children follow (use │)
var traverse func(*filetree.FileNode, []bool)
traverse = func(node *filetree.FileNode, levels []bool) {
if node == nil {
return
}
// Generate tree prefix based on levels (compact, 2 chars per level)
// Example levels: [false, true] -> "│ └─"
var prefixBuilder strings.Builder
for i, isLast := range levels {
if i == len(levels)-1 {
// Current level (the node itself) - 2 chars
if isLast {
prefixBuilder.WriteString("└─") // Was "└── "
} else {
prefixBuilder.WriteString("├─") // Was "├── "
}
} else {
// Parent levels (indentation) - 2 chars
if isLast {
prefixBuilder.WriteString(" ") // Was " "
} else {
prefixBuilder.WriteString("│ ") // Was "│ "
}
}
}
// Add current node (skip root when rendering)
if node.Parent != nil {
nodes = append(nodes, VisibleNode{
Node: node,
Prefix: prefixBuilder.String(),
})
}
// Recurse into children if directory and not collapsed
if node.Data.FileInfo.IsDir() && !node.Data.ViewInfo.Collapsed {
sortedChildren := SortChildren(node.Children)
count := len(sortedChildren)
for i, child := range sortedChildren {
// Create new levels array for child
isLastChild := i == count-1
newLevels := make([]bool, len(levels)+1)
copy(newLevels, levels)
newLevels[len(levels)] = isLastChild
traverse(child, newLevels)
}
}
}
// Start from root
if !root.Data.ViewInfo.Collapsed {
sortedChildren := SortChildren(root.Children)
count := len(sortedChildren)
for i, child := range sortedChildren {
traverse(child, []bool{i == count - 1})
}
}
return nodes
}
// SortChildren sorts node children: directories first, then files, all alphabetically
func SortChildren(children map[string]*filetree.FileNode) []*filetree.FileNode {
if children == nil {
return nil
}
// Split into directories and files
var dirs []*filetree.FileNode
var files []*filetree.FileNode
for _, child := range children {
if child.Data.FileInfo.IsDir() {
dirs = append(dirs, child)
} else {
files = append(files, child)
}
}
// Sort directories
sort.Slice(dirs, func(i, j int) bool {
return dirs[i].Name < dirs[j].Name
})
// Sort files
sort.Slice(files, func(i, j int) bool {
return files[i].Name < files[j].Name
})
// Combine: directories first, then files
result := append(dirs, files...)
return result
}
// FindParentIndex finds the index of the parent directory of the node at the given index
// Returns -1 if the node has no parent or parent is not visible
func FindParentIndex(visibleNodes []VisibleNode, currentIndex int) int {
if currentIndex < 0 || currentIndex >= len(visibleNodes) {
return -1
}
currentNode := visibleNodes[currentIndex].Node
parentNode := currentNode.Parent
// Root node has no parent
if parentNode == nil || parentNode.Parent == nil {
// parent.Parent == nil means parent is actually the root node
return -1
}
// Find the parent in the visible nodes
for i, vn := range visibleNodes {
if vn.Node == parentNode {
return i
}
}
// Parent exists but is not visible (e.g., collapsed ancestor)
return -1
}

View file

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

View file

@ -139,13 +139,17 @@ func (m *Pane) updateContent() {
func (m *Pane) generateContent() string {
width := m.width - 2 // Subtract borders
// Count files > 0 bytes
filesGreaterThanZeroKB := m.countFilesAboveZeroBytes()
// Header with statistics
headerText := fmt.Sprintf(
"Image name: %s\nTotal Image size: %s\nPotential wasted space: %s\nImage efficiency score: %.0f%%",
"Image name: %s\nTotal Image size: %s\nPotential wasted space: %s\nImage efficiency score: %.0f%%\nFiles > 0 KB total: %d",
m.analysis.Image,
utils.FormatSize(m.analysis.SizeBytes),
utils.FormatSize(m.analysis.WastedBytes),
m.analysis.Efficiency*100,
filesGreaterThanZeroKB,
)
// Table header
@ -173,3 +177,20 @@ func (m *Pane) generateContent() string {
return fullContent.String()
}
// countFilesAboveZeroBytes counts the total number of files with size > 0 bytes across all inefficiencies
func (m *Pane) countFilesAboveZeroBytes() int {
if m.analysis == nil {
return 0
}
count := 0
for _, ineff := range m.analysis.Inefficiencies {
for _, node := range ineff.Nodes {
if node.Size > 0 {
count++
}
}
}
return count
}

View file

@ -11,8 +11,11 @@ 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/components"
"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"
"github.com/wagoodman/dive/dive/image"
)
// LayerChangedMsg is sent when the active layer changes
@ -20,25 +23,54 @@ type LayerChangedMsg struct {
LayerIndex int
}
// ShowLayerDetailMsg is sent to show the layer detail modal
type ShowLayerDetailMsg struct {
Layer *image.Layer
}
// Define layout constants to ensure click detection matches rendering
const (
ColWidthPrefix = 1 // " "
ColWidthID = 12
ColWidthSize = 9
ColPadding = 1
// Calculation: Prefix(1) + ID(12) + Pad(1) + Size(9) + Pad(1)
StatsStartOffset = ColWidthPrefix + ColWidthID + ColPadding + ColWidthSize + ColPadding
)
// Pane manages the layers list
type Pane struct {
focused bool
width int
height int
layerVM *viewmodel.LayerSetState
viewport viewport.Model
layerIndex int
focused bool
width int
height int
layerVM *viewmodel.LayerSetState
comparer *filetree.Comparer // For computing layer comparison trees
viewport viewport.Model
layerIndex int
statsRows []components.FileStatsRow // Stats row for each layer
}
// New creates a new layers pane
func New(layerVM *viewmodel.LayerSetState) Pane {
func New(layerVM *viewmodel.LayerSetState, comparer filetree.Comparer) Pane {
vp := viewport.New(80, 20)
// Initialize stats rows
var statsRows []components.FileStatsRow
if layerVM != nil && len(layerVM.Layers) > 0 {
statsRows = make([]components.FileStatsRow, len(layerVM.Layers))
for i := range layerVM.Layers {
statsRows[i] = components.NewFileStatsRow()
}
}
p := Pane{
layerVM: layerVM,
comparer: &comparer,
viewport: vp,
layerIndex: 0,
width: 80,
height: 20,
statsRows: statsRows,
}
// IMPORTANT: Generate content immediately so viewport is not empty on startup
p.updateContent()
@ -123,6 +155,13 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.moveUp())
case "down", "j":
cmds = append(cmds, m.moveDown())
case " ":
// Show layer detail modal
if m.layerVM != nil && m.layerIndex >= 0 && m.layerIndex < len(m.layerVM.Layers) {
cmds = append(cmds, func() tea.Msg {
return ShowLayerDetailMsg{Layer: m.layerVM.Layers[m.layerIndex]}
})
}
}
case tea.MouseMsg:
@ -189,10 +228,12 @@ func (m *Pane) moveDown() tea.Cmd {
// handleClick processes a mouse click
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
@ -203,6 +244,31 @@ func (m *Pane) handleClick(x, y int) tea.Cmd {
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*.
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
if targetIndex < len(m.statsRows) {
partType, found := m.statsRows[targetIndex].GetPartAtPosition(contentX, StatsStartOffset)
if found {
// Click on a stats part - toggle that specific part
part := m.statsRows[targetIndex].GetPart(partType)
if part != nil {
part.ToggleActive()
m.updateContent()
return nil
}
}
}
// 5. Click outside stats - select layer
return m.SetLayerIndex(targetIndex)
}
@ -219,37 +285,80 @@ func (m *Pane) updateContent() {
// generateContent creates the layers content
func (m *Pane) generateContent() string {
width := m.width - 2
const (
idWidth = 12
sizeWidth = 9
spaces = 4
)
width := m.width - 2 // Viewport width (without panel borders)
var fullContent strings.Builder
for i, layer := range m.layerVM.Layers {
prefix := " "
prefix := " "
style := lipgloss.NewStyle()
if i == m.layerIndex {
prefix = "● "
// No bullet, just color highlighting
style = styles.SelectedLayerStyle
}
// Format ID
id := layer.Id
if len(id) > idWidth {
id = id[:idWidth]
if len(id) > ColWidthID {
id = id[:ColWidthID]
}
// Format Size
size := utils.FormatSize(layer.Size)
// Update and get stats from component
statsStr := ""
statsVisualWidth := 9 // Default approximate width
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)
m.statsRows[i].SetStats(stats)
statsStr = m.statsRows[i].Render()
// Calculate total visual width for command truncation math
addedW := m.statsRows[i].GetAdded().GetVisualWidth()
modW := m.statsRows[i].GetModified().GetVisualWidth()
remW := m.statsRows[i].GetRemoved().GetVisualWidth()
statsVisualWidth = addedW + 1 + modW + 1 + remW // +1 for spaces
}
// Clean command from newlines
rawCmd := strings.ReplaceAll(layer.Command, "\n", " ")
rawCmd = strings.TrimSpace(rawCmd)
availableCmdWidth := width - 2 - idWidth - spaces - sizeWidth
if availableCmdWidth < 5 {
// Calculate available space for command
// Logic must match StatsStartOffset constants
// Used = Prefix(1) + ID(12) + Pad(1) + Size(9) + Pad(1) + StatsWidth + Pad(1)
usedWidth := StatsStartOffset + statsVisualWidth + 1
availableCmdWidth := width - usedWidth
if availableCmdWidth < 0 {
availableCmdWidth = 0
}
@ -258,12 +367,22 @@ func (m *Pane) generateContent() string {
cmd = runewidth.Truncate(rawCmd, availableCmdWidth, "...")
}
text := fmt.Sprintf("%s%-*s %*s %s", prefix, idWidth, id, sizeWidth, size, cmd)
maxLineWidth := width
if runewidth.StringWidth(text) > maxLineWidth {
text = runewidth.Truncate(text, maxLineWidth, "")
}
// Build the line using strict column widths
// %-1s = Prefix
// %-*s = ID (left align, width 12)
// " " = Padding
// %*s = Size (right align, width 9)
// " " = Padding
// %s = Stats
// " " = Padding
// %s = Command
text := fmt.Sprintf("%-1s%-*s %*s %s %s",
prefix,
ColWidthID, id,
ColWidthSize, size,
statsStr,
cmd,
)
fullContent.WriteString(style.Render(text))
fullContent.WriteString("\n")

View file

@ -3,9 +3,9 @@ package styles
// --- File Icons ---
var (
IconDirOpen = "📂 "
IconDirClosed = "📁 "
IconFile = "📄 "
IconDirOpen = "󰝰 " // nf-md-folder_open
IconDirClosed = "󰉋 " // nf-md-folder
IconFile = "󰈔 " // nf-md-file
IconSymlink = "🔗 "
)

View file

@ -56,6 +56,23 @@ var FileTreeModifiedStyle = lipgloss.NewStyle().
Foreground(WarningColor).
Bold(true)
// --- File Stats Styles ---
// FileStatsAddedStyle for added files count
var FileStatsAddedStyle = lipgloss.NewStyle().
Foreground(SuccessColor).
Bold(true)
// FileStatsModifiedStyle for modified files count
var FileStatsModifiedStyle = lipgloss.NewStyle().
Foreground(WarningColor).
Bold(true)
// FileStatsRemovedStyle for removed files count
var FileStatsRemovedStyle = lipgloss.NewStyle().
Foreground(ErrorColor).
Bold(true)
// --- Rendering Functions ---
// RenderBox creates a bordered box with title and content
@ -116,3 +133,13 @@ func RenderBox(title string, width, height int, content string, isSelected bool)
func TruncateString(s string, maxLen int) string {
return runewidth.Truncate(s, maxLen, "...")
}
// --- File Tree Visual Styles ---
// TreeGuideStyle for tree guide lines (│ ├ └)
var TreeGuideStyle = lipgloss.NewStyle().
Foreground(DarkGrayColor)
// MetaDataStyle for permissions, UID, and size (muted, less prominent)
var MetaDataStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6e6e73"))

View file

@ -0,0 +1,39 @@
package utils
import (
"github.com/wagoodman/dive/dive/filetree"
)
// FileStats holds file change statistics
type FileStats struct {
Added int
Modified int
Removed int
}
// CalculateFileStats walks the file tree and counts file changes
func CalculateFileStats(tree *filetree.FileTree) FileStats {
stats := FileStats{}
if tree == nil || tree.Root == nil {
return stats
}
visitor := func(node *filetree.FileNode) error {
// Only count leaf nodes (actual files, not directories)
if len(node.Children) == 0 {
switch node.Data.DiffType {
case filetree.Added:
stats.Added++
case filetree.Modified:
stats.Modified++
case filetree.Removed:
stats.Removed++
}
}
return nil
}
_ = tree.VisitDepthChildFirst(visitor, nil)
return stats
}