feat: Introduce domain layer for image and file statistics

- Added a new `common.Pane` interface to standardize UI pane interactions.
- Created `domain.ImageStats` and `domain.FileStats` structures to encapsulate image and file change statistics.
- Implemented `CalculateImageStats` and `CalculateFileStats` functions to compute statistics from image analysis and file trees, respectively.
- Refactored existing UI panes to utilize the new domain logic for calculating statistics, improving separation of concerns.
- Updated tests to validate the new domain logic and ensure accurate statistics calculation.
- Renamed pane methods from `SetSize` to `Resize` for consistency across the codebase.
This commit is contained in:
Aslan Dukaev 2026-01-16 14:06:59 +03:00
commit ecbad850ac
17 changed files with 637 additions and 276 deletions

View file

@ -7,6 +7,11 @@ import (
v2styles "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
)
// FilterAppliedMsg is sent when the user applies a filter
type FilterAppliedMsg struct {
Pattern string
}
// FilterModel manages the filter input modal
type FilterModel struct {
textinput.Model
@ -57,8 +62,12 @@ func (m FilterModel) Update(msg tea.Msg) (FilterModel, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
// TODO: Apply filter
return m, nil
// Apply filter and hide
pattern := m.Value()
m.Hide()
return m, func() tea.Msg {
return FilterAppliedMsg{Pattern: pattern}
}
case "esc":
m.Hide()
return m, nil

View file

@ -98,3 +98,63 @@ func (r Result) GetViewportDimensions(paneWidth, paneHeight int) (width, height
return width, height
}
// PaneID represents a pane identifier (matches app.Pane values)
type PaneID int
const (
PaneIDLayer PaneID = 0
PaneIDDetails PaneID = 1
PaneIDImage PaneID = 2
PaneIDTree PaneID = 3
)
// GetPaneAt determines which pane is at the given global coordinates.
// Returns: (paneID, localX, localY, found)
//
// Parameters:
// - x, y: Global screen coordinates
// - totalWidth: Total screen width
//
// The returned localX, localY are coordinates relative to the pane's content area
// (accounting for borders and titles as appropriate).
func (r Result) GetPaneAt(x, y, totalWidth int) (PaneID, int, int, bool) {
// Check bounds
if x < 0 || y < r.ContentStartY {
return 0, 0, 0, false
}
// Determine which column
inLeftCol := x < r.LeftWidth
inRightCol := x >= r.LeftWidth && x < totalWidth
if inLeftCol {
// Determine which pane in left column
layersEndY := r.ContentStartY + r.LayersHeight
detailsEndY := layersEndY + r.DetailsHeight
if y < layersEndY {
// Layers pane
localX := x
localY := y - r.ContentStartY
return PaneIDLayer, localX, localY, true
} else if y >= layersEndY && y < detailsEndY {
// Details pane (read-only)
localX := x
localY := y - layersEndY
return PaneIDDetails, localX, localY, true
} else {
// Image pane
localX := x
localY := y - detailsEndY
return PaneIDImage, localX, localY, true
}
} else if inRightCol {
// Tree pane
localX := x - r.LeftWidth
localY := y - r.ContentStartY
return PaneIDTree, localX, localY, true
}
return 0, 0, 0, false
}

View file

@ -2,6 +2,7 @@ package app
import (
"context"
"regexp"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/help"
@ -52,15 +53,22 @@ func (p Pane) String() string {
return "Unknown"
}
// LayoutCache stores calculated pane dimensions to avoid recalculating in View and Mouse
type LayoutCache struct {
ContentStartY int
LeftWidth int
RightWidth int
LayersHeight int
DetailsHeight int
ImageHeight int
TreeHeight int
// mapLayoutPaneToAppPane safely converts layout.PaneID to app.Pane
// This prevents bugs if the order of constants changes in either package
func mapLayoutPaneToAppPane(id layout.PaneID) Pane {
switch id {
case layout.PaneIDLayer:
return PaneLayer
case layout.PaneIDDetails:
return PaneDetails
case layout.PaneIDImage:
return PaneImage
case layout.PaneIDTree:
return PaneTree
default:
// Fallback to Layers if unknown
return PaneLayer
}
}
// Model is the bubbletea Model for V2UI
@ -79,19 +87,18 @@ type Model struct {
width int
height int
quitting bool
layout LayoutCache
layout layout.Result // Stores calculated pane dimensions from layout engine
// Pane components (independent tea.Models)
layersPane layers.Pane
detailsPane details.Pane
imagePane imagepane.Pane
treePane filetreepane.Pane
// Panes stored by interface (polymorphic access)
// No need for concrete types - interface handles everything
panes map[Pane]common.Pane
// Active pane state
activePane Pane
// Filter state
filter FilterModel
filter FilterModel
filterRegex *regexp.Regexp // Compiled regex for tree filtering
// Layer detail modal
layerDetailModal LayerDetailModal
@ -143,6 +150,7 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
treePane := filetreepane.New(treeVM)
// Create model with initial dimensions
// POLYMORPHISM: Store all panes as common.Pane interface
model := Model{
analysis: analysis,
content: content,
@ -150,10 +158,12 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
ctx: ctx,
layerVM: layerVM,
treeVM: treeVM,
layersPane: layersPane,
detailsPane: detailsPane,
imagePane: imagePane,
treePane: treePane,
panes: map[Pane]common.Pane{
PaneLayer: &layersPane,
PaneDetails: &detailsPane,
PaneImage: &imagePane,
PaneTree: &treePane,
},
width: 80,
height: 24,
quitting: false,
@ -178,18 +188,12 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
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)
// Update all panes with initial layout
// POLYMORPHISM: Same code for all panes, no type assertions needed!
for paneType, pane := range model.panes {
updatedPane, _ := pane.Update(layoutMsg)
model.panes[paneType] = updatedPane
}
return model
}
@ -211,8 +215,9 @@ func (m Model) Init() tea.Cmd {
Layer: m.layerVM.Layers[layerIndex],
LayerIndex: layerIndex,
}
newDetails, _ := m.detailsPane.Update(layerMsg)
m.detailsPane = newDetails.(details.Pane)
// POLYMORPHISM: Update pane through interface, no type assertion
newDetails, _ := m.panes[PaneDetails].Update(layerMsg)
m.panes[PaneDetails] = newDetails
}
}
@ -260,24 +265,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// Route keys to focused pane
switch m.activePane {
case PaneLayer:
newPane, cmd := m.layersPane.Update(msg)
m.layersPane = newPane.(layers.Pane)
cmds = append(cmds, cmd)
case PaneDetails:
// Details pane is read-only, no keyboard handling
case PaneImage:
newPane, cmd := m.imagePane.Update(msg)
m.imagePane = newPane.(imagepane.Pane)
cmds = append(cmds, cmd)
case PaneTree:
newPane, cmd := m.treePane.Update(msg)
m.treePane = newPane.(filetreepane.Pane)
cmds = append(cmds, cmd)
// POLYMORPHISM: No switch-case needed - just get the active pane from the map!
if activePane, ok := m.panes[m.activePane]; ok {
// Details pane is read-only, skip it
if m.activePane != PaneDetails {
updatedPane, cmd := activePane.Update(msg)
m.panes[m.activePane] = updatedPane
cmds = append(cmds, cmd)
}
}
// Global key bindings
@ -293,6 +288,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.filter.Show()
}
case FilterAppliedMsg:
// User applied a filter pattern
m.applyFilter(msg.Pattern)
case layers.LayerChangedMsg:
// Layer changed - update details pane and tree via messages
if m.layerVM != nil && msg.LayerIndex >= 0 && msg.LayerIndex < len(m.layerVM.Layers) {
@ -300,8 +299,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Layer: m.layerVM.Layers[msg.LayerIndex],
LayerIndex: msg.LayerIndex,
}
newDetails, _ := m.detailsPane.Update(layerMsg)
m.detailsPane = newDetails.(details.Pane)
// POLYMORPHISM: Update through interface
newDetails, _ := m.panes[PaneDetails].Update(layerMsg)
m.panes[PaneDetails] = newDetails
}
m.updateTreeForCurrentLayer()
@ -310,89 +310,52 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// CRITICAL: This fixes the copy-on-write issue. The InputHandler's callback
// modified the collapsed flag in the tree data, but the visible copy of
// treePane (stored in this Model) needs to refresh its cache to show changes.
newPane, cmd := m.treePane.Update(msg)
m.treePane = newPane.(filetreepane.Pane)
// POLYMORPHISM: Update through interface
newPane, cmd := m.panes[PaneTree].Update(msg)
m.panes[PaneTree] = newPane
cmds = append(cmds, cmd)
case filetreepane.RefreshTreeContentMsg:
// Request to refresh tree content
m.treePane.SetTreeVM(m.treeVM)
// NOTE: SetTreeVM is tree-specific, so we need a type assertion here
// This is acceptable since it's a one-time operation for tree-specific functionality
if treePane, ok := m.panes[PaneTree].(*filetreepane.Pane); ok {
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 with coordinate transformation
// Parent handles ALL coordinate math - children receive simple local coordinates
x, y := msg.X, msg.Y
l := m.layout
// Layout engine determines which pane was clicked and provides local coordinates
// This encapsulates hit testing logic in the layout layer
paneID, localX, localY, found := m.layout.GetPaneAt(msg.X, msg.Y, m.width)
inLeftCol := x >= 0 && x < l.LeftWidth
inRightCol := x >= l.LeftWidth && x < m.width
if found {
// SAFETY: Use explicit mapping instead of type conversion
// This prevents bugs if constant order changes in either package
targetPane := mapLayoutPaneToAppPane(paneID)
if inLeftCol {
// Determine which pane in left column
layersEndY := l.ContentStartY + l.LayersHeight
detailsEndY := layersEndY + l.DetailsHeight
if y < layersEndY {
// 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.sendFocusStates()
}
} else if y >= layersEndY && y < detailsEndY {
// Details pane (read-only, no mouse handling)
if m.activePane != PaneDetails {
m.activePane = PaneDetails
m.sendFocusStates()
}
} else {
// 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.sendFocusStates()
}
}
} else if inRightCol {
// 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
// Change focus if needed
if m.activePane != targetPane {
m.activePane = targetPane
m.sendFocusStates()
}
// Skip Details pane (read-only, no mouse handling)
if targetPane != PaneDetails {
// Create local mouse message with transformed coordinates
localMsg := common.LocalMouseMsg{
MouseMsg: msg,
LocalX: localX,
LocalY: localY,
}
// POLYMORPHISM: Update through interface
newPane, cmd := m.panes[targetPane].Update(localMsg)
m.panes[targetPane] = newPane
cmds = append(cmds, cmd)
}
}
case tea.WindowSizeMsg:
@ -413,24 +376,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// Broadcast to all panes - they will extract what they need
// POLYMORPHISM: Same code for all panes, no type assertions needed!
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)
for paneType, pane := range m.panes {
updatedPane, cmd := pane.Update(layoutMsg)
m.panes[paneType] = updatedPane
layoutCmds = append(layoutCmds, cmd)
}
cmds = append(cmds, layoutCmds...)
}
@ -455,37 +407,27 @@ func (m *Model) togglePane() {
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:
newPane, _ := m.layersPane.Update(layers.FocusStateMsg{Focused: true})
m.layersPane = newPane.(layers.Pane)
case PaneDetails:
newPane, _ := m.detailsPane.Update(details.FocusStateMsg{Focused: true})
m.detailsPane = newPane.(details.Pane)
case PaneImage:
newPane, _ := m.imagePane.Update(imagepane.FocusStateMsg{Focused: true})
m.imagePane = newPane.(imagepane.Pane)
case PaneTree:
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)
// POLYMORPHISM: Iterate over all panes and update their focus state
for paneType, pane := range m.panes {
focused := (paneType == m.activePane)
// Create the appropriate FocusStateMsg for this pane type
var focusMsg tea.Msg
switch paneType {
case PaneLayer:
focusMsg = layers.FocusStateMsg{Focused: focused}
case PaneDetails:
focusMsg = details.FocusStateMsg{Focused: focused}
case PaneImage:
focusMsg = imagepane.FocusStateMsg{Focused: focused}
case PaneTree:
focusMsg = filetreepane.FocusStateMsg{Focused: focused}
}
// POLYMORPHISM: Update through interface, no type assertion
updatedPane, _ := pane.Update(focusMsg)
m.panes[paneType] = updatedPane
}
}
@ -502,7 +444,10 @@ func (m *Model) updateTreeForCurrentLayer() {
_ = m.treeVM.Update(nil, m.layout.RightWidth, m.layout.TreeHeight)
// Update tree pane with new tree data
m.treePane.SetTreeVM(m.treeVM)
// NOTE: SetTreeVM is tree-specific, so we need a type assertion here
if treePane, ok := m.panes[PaneTree].(*filetreepane.Pane); ok {
treePane.SetTreeVM(m.treeVM)
}
}
// View implements tea.Model (PURE FUNCTION - no side effects!)
@ -520,12 +465,13 @@ func (m Model) View() string {
statusBar := m.help.View(m.keys)
// Render panes directly using their View() methods
// POLYMORPHISM: Access panes through interface from map
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
m.layersPane.View(),
m.detailsPane.View(),
m.imagePane.View(),
m.panes[PaneLayer].View(),
m.panes[PaneDetails].View(),
m.panes[PaneImage].View(),
)
treePane := m.treePane.View()
treePane := m.panes[PaneTree].View()
mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, treePane)
@ -549,3 +495,27 @@ func (m Model) View() string {
return base
}
// applyFilter applies a filter pattern to the file tree
func (m *Model) applyFilter(pattern string) {
if m.treeVM == nil {
return
}
// Compile the regex pattern
var filterRegex *regexp.Regexp
if pattern != "" {
filterRegex = regexp.MustCompile(pattern)
}
m.filterRegex = filterRegex
// Update the tree viewmodel with the filter
// This will update ViewTree based on the filter
_ = m.treeVM.Update(filterRegex, m.layout.RightWidth, m.layout.TreeHeight)
// Update tree pane with filtered tree data
// NOTE: SetTreeVM is tree-specific, so we need a type assertion here
if treePane, ok := m.panes[PaneTree].(*filetreepane.Pane); ok {
treePane.SetTreeVM(m.treeVM)
}
}

View file

@ -0,0 +1,27 @@
package common
import tea "github.com/charmbracelet/bubbletea"
// Pane defines the interface that all UI panes must implement.
// This interface enables polymorphic interaction between the main app model
// and child panes, eliminating type assertions and tight coupling.
type Pane interface {
// Init initializes the pane component
Init() tea.Cmd
// Update handles incoming messages and returns the updated pane.
// Returns Pane (not tea.Model) to enable polymorphic updates without type assertions.
Update(msg tea.Msg) (Pane, tea.Cmd)
// View renders the pane to a string
View() string
// Resize updates the pane dimensions. Called when the terminal is resized
// or when the layout engine recalculates pane sizes.
Resize(width, height int)
// SetFocused sets the focus state of the pane.
// When focused, the pane handles keyboard input.
// When unfocused, the pane ignores keyboard input.
SetFocused(focused bool)
}

View file

@ -4,12 +4,13 @@ import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/domain"
"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
// Re-export FileStats from domain package
type FileStats = domain.FileStats
// StatsPartType represents which part of the stats this is
type StatsPartType int

View file

@ -0,0 +1,65 @@
package domain
import (
"github.com/wagoodman/dive/dive/image"
)
// ImageStats holds calculated image statistics for display
type ImageStats struct {
ImageName string
TotalSizeBytes uint64
WastedBytes uint64
EfficiencyScore float64
FilesAboveZeroKB int
InefficiencyCount int
}
// CalculateImageStats computes statistics from an image analysis.
// This is a pure function that extracts business logic from the UI layer.
func CalculateImageStats(analysis *image.Analysis) ImageStats {
if analysis == nil {
return ImageStats{}
}
filesAboveZeroKB := countFilesAboveZeroBytes(analysis)
inefficiencyCount := countInefficiencies(analysis)
return ImageStats{
ImageName: analysis.Image,
TotalSizeBytes: analysis.SizeBytes,
WastedBytes: analysis.WastedBytes,
EfficiencyScore: analysis.Efficiency * 100, // Convert to percentage
FilesAboveZeroKB: filesAboveZeroKB,
InefficiencyCount: inefficiencyCount,
}
}
// countFilesAboveZeroBytes counts the total number of files with size > 0 bytes across all inefficiencies
func countFilesAboveZeroBytes(analysis *image.Analysis) int {
if analysis == nil {
return 0
}
count := 0
for _, ineff := range analysis.Inefficiencies {
for _, node := range ineff.Nodes {
if node.Data.FileInfo.Size > 0 {
count++
}
}
}
return count
}
// countInefficiencies counts the number of inefficiencies with cumulative size > 0
func countInefficiencies(analysis *image.Analysis) int {
if analysis == nil {
return 0
}
count := 0
for _, ineff := range analysis.Inefficiencies {
if ineff.CumulativeSize > 0 {
count++
}
}
return count
}

View file

@ -0,0 +1,223 @@
package domain
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/dive/image"
)
func TestCalculateImageStats(t *testing.T) {
tests := []struct {
name string
analysis *image.Analysis
want ImageStats
}{
{
name: "nil analysis",
analysis: nil,
want: ImageStats{},
},
{
name: "empty analysis",
analysis: &image.Analysis{
Image: "test-image",
SizeBytes: 1000,
WastedBytes: 100,
Efficiency: 0.9,
Inefficiencies: filetree.EfficiencySlice{},
},
want: ImageStats{
ImageName: "test-image",
TotalSizeBytes: 1000,
WastedBytes: 100,
EfficiencyScore: 90.0,
FilesAboveZeroKB: 0,
InefficiencyCount: 0,
},
},
{
name: "analysis with inefficiencies",
analysis: &image.Analysis{
Image: "test-image",
SizeBytes: 5000,
WastedBytes: 2000,
Efficiency: 0.6,
Inefficiencies: filetree.EfficiencySlice{
{
Path: "/path/to/file1",
CumulativeSize: 100,
Nodes: []*filetree.FileNode{
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 50}}},
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 0}}}, // Should not be counted
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 100}}},
},
},
{
Path: "/path/to/file2",
CumulativeSize: 200,
Nodes: []*filetree.FileNode{
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 200}}},
},
},
{
Path: "/path/to/empty",
CumulativeSize: 0, // Should not be counted as inefficiency
Nodes: []*filetree.FileNode{
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 0}}},
},
},
},
},
want: ImageStats{
ImageName: "test-image",
TotalSizeBytes: 5000,
WastedBytes: 2000,
EfficiencyScore: 60.0,
FilesAboveZeroKB: 3, // 50, 100, 200 are > 0
InefficiencyCount: 2, // Only 2 inefficiencies have CumulativeSize > 0
},
},
{
name: "efficiency calculation",
analysis: &image.Analysis{
Image: "efficiency-test",
SizeBytes: 10000,
WastedBytes: 1000,
Efficiency: 0.9,
Inefficiencies: filetree.EfficiencySlice{
{
Path: "/file",
CumulativeSize: 500,
Nodes: []*filetree.FileNode{
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 500}}},
},
},
},
},
want: ImageStats{
ImageName: "efficiency-test",
TotalSizeBytes: 10000,
WastedBytes: 1000,
EfficiencyScore: 90.0, // 0.9 * 100
FilesAboveZeroKB: 1,
InefficiencyCount: 1,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CalculateImageStats(tt.analysis)
assert.Equal(t, tt.want, got)
})
}
}
func TestCountFilesAboveZeroBytes(t *testing.T) {
tests := []struct {
name string
analysis *image.Analysis
want int
}{
{
name: "nil analysis",
analysis: nil,
want: 0,
},
{
name: "no inefficiencies",
analysis: &image.Analysis{
Inefficiencies: filetree.EfficiencySlice{},
},
want: 0,
},
{
name: "mixed file sizes",
analysis: &image.Analysis{
Inefficiencies: filetree.EfficiencySlice{
{
Nodes: []*filetree.FileNode{
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 100}}},
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 0}}},
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 50}}},
},
},
},
},
want: 2, // Only 100 and 50 are > 0
},
{
name: "all zero size files",
analysis: &image.Analysis{
Inefficiencies: filetree.EfficiencySlice{
{
Nodes: []*filetree.FileNode{
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 0}}},
{Data: filetree.NodeData{FileInfo: filetree.FileInfo{Size: 0}}},
},
},
},
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := countFilesAboveZeroBytes(tt.analysis)
assert.Equal(t, tt.want, got)
})
}
}
func TestCountInefficiencies(t *testing.T) {
tests := []struct {
name string
analysis *image.Analysis
want int
}{
{
name: "nil analysis",
analysis: nil,
want: 0,
},
{
name: "no inefficiencies",
analysis: &image.Analysis{
Inefficiencies: filetree.EfficiencySlice{},
},
want: 0,
},
{
name: "mixed cumulative sizes",
analysis: &image.Analysis{
Inefficiencies: filetree.EfficiencySlice{
{CumulativeSize: 100},
{CumulativeSize: 0},
{CumulativeSize: 50},
},
},
want: 2, // Only 2 have CumulativeSize > 0
},
{
name: "all zero cumulative size",
analysis: &image.Analysis{
Inefficiencies: filetree.EfficiencySlice{
{CumulativeSize: 0},
{CumulativeSize: 0},
},
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := countInefficiencies(tt.analysis)
assert.Equal(t, tt.want, got)
})
}
}

View file

@ -1,4 +1,4 @@
package utils
package domain
import (
"github.com/wagoodman/dive/dive/filetree"
@ -11,7 +11,8 @@ type FileStats struct {
Removed int
}
// CalculateFileStats walks the file tree and counts file changes
// CalculateFileStats walks the file tree and counts file changes.
// This is a pure function that extracts business logic from the UI layer.
func CalculateFileStats(tree *filetree.FileTree) FileStats {
stats := FileStats{}

View file

@ -35,8 +35,8 @@ func New() Pane {
}
}
// SetSize updates the pane dimensions
func (m *Pane) SetSize(width, height int) {
// Resize updates the pane dimensions
func (m *Pane) Resize(width, height int) {
m.width = width
m.height = height
}
@ -47,17 +47,22 @@ func (m *Pane) SetLayer(layer *image.Layer) {
}
// Init initializes the pane
func (m Pane) Init() tea.Cmd {
func (m *Pane) Init() tea.Cmd {
return nil
}
// SetFocused sets the focus state of the pane
func (m *Pane) SetFocused(focused bool) {
m.focused = focused
}
// Update handles messages
func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Pane) Update(msg tea.Msg) (common.Pane, tea.Cmd) {
switch msg := msg.(type) {
case common.LayoutMsg:
// Parent sends layout info instead of calling SetSize()
// Parent sends layout info instead of calling Resize()
// Extract what we need from the message
m.SetSize(msg.LeftWidth, msg.DetailsHeight)
m.Resize(msg.LeftWidth, msg.DetailsHeight)
return m, nil
case common.LayerSelectedMsg:
@ -66,8 +71,8 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case FocusStateMsg:
// Parent controls focus state - this is the Single Source of Truth pattern
m.focused = msg.Focused
// Parent controls focus state - use SetFocused method
m.SetFocused(msg.Focused)
return m, nil
}
// Details pane doesn't handle any other messages - it's read-only

View file

@ -11,7 +11,7 @@ import (
func TestPane_View_NoLayer(t *testing.T) {
pane := New()
pane.SetSize(50, 10)
pane.Resize(50, 10)
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -30,7 +30,7 @@ func TestPane_View_WithLayer(t *testing.T) {
pane := New()
pane.SetLayer(layer)
pane.SetSize(80, 15)
pane.Resize(80, 15)
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -49,12 +49,12 @@ func TestPane_View_Focused(t *testing.T) {
pane := New()
pane.SetLayer(layer)
pane.SetSize(80, 15)
pane.Resize(80, 15)
// Send focus message
updatedPane, _ := pane.Update(FocusStateMsg{Focused: true})
view := updatedPane.(Pane).View()
view := updatedPane.(*Pane).View()
snaps.MatchSnapshot(t, view)
}
@ -71,7 +71,7 @@ func TestPane_View_SmallWidth(t *testing.T) {
pane := New()
pane.SetLayer(layer)
pane.SetSize(30, 15) // Very narrow width
pane.Resize(30, 15) // Very narrow width
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -90,7 +90,7 @@ func TestPane_View_SmallHeight(t *testing.T) {
pane := New()
pane.SetLayer(layer)
pane.SetSize(80, 6) // Very short height
pane.Resize(80, 6) // Very short height
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -109,7 +109,7 @@ func TestPane_View_LargeSize(t *testing.T) {
pane := New()
pane.SetLayer(layer)
pane.SetSize(120, 30) // Large dimensions
pane.Resize(120, 30) // Large dimensions
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -134,7 +134,7 @@ func TestPane_View_LongCommand(t *testing.T) {
pane := New()
pane.SetLayer(targetLayer)
pane.SetSize(80, 15)
pane.Resize(80, 15)
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -159,6 +159,6 @@ func TestPane_Update_WithLayoutMsg(t *testing.T) {
updatedPane, _ := pane.Update(layoutMsg)
// Verify size was updated
view := updatedPane.(Pane).View()
view := updatedPane.(*Pane).View()
snaps.MatchSnapshot(t, view)
}

View file

@ -71,8 +71,8 @@ func New(treeVM *viewmodel.FileTreeViewModel) Pane {
return p
}
// SetSize updates the pane dimensions
func (p *Pane) SetSize(width, height int) {
// Resize updates the pane dimensions
func (p *Pane) Resize(width, height int) {
p.width = width
p.height = height
@ -108,21 +108,26 @@ func (p *Pane) GetTreeIndex() int {
}
// Init initializes the pane
func (p Pane) Init() tea.Cmd {
func (p *Pane) Init() tea.Cmd {
return nil
}
// SetFocused sets the focus state of the pane
func (p *Pane) SetFocused(focused bool) {
p.focused = focused
}
// Update handles messages
func (p Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (p *Pane) Update(msg tea.Msg) (common.Pane, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case common.LayoutMsg:
p.SetSize(msg.RightWidth, msg.TreeHeight)
p.Resize(msg.RightWidth, msg.TreeHeight)
return p, nil
case FocusStateMsg:
p.focused = msg.Focused
p.SetFocused(msg.Focused)
return p, nil
case common.LocalMouseMsg:

View file

@ -13,7 +13,7 @@ import (
func TestPane_View_EmptyTree(t *testing.T) {
// Test with nil treeVM
pane := New(nil)
pane.SetSize(50, 20)
pane.Resize(50, 20)
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -24,7 +24,7 @@ func TestPane_View_WithTree(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.TreeVM)
pane.SetSize(50, 20)
pane.Resize(50, 20)
// Initialize the pane
cmd := pane.Init()
@ -39,12 +39,12 @@ func TestPane_View_Focused(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.TreeVM)
pane.SetSize(50, 20)
pane.Resize(50, 20)
// Send focus message
updatedPane, _ := pane.Update(FocusStateMsg{Focused: true})
view := updatedPane.(Pane).View()
view := updatedPane.(*Pane).View()
snaps.MatchSnapshot(t, view)
}
@ -53,7 +53,7 @@ func TestPane_View_SmallWidth(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.TreeVM)
pane.SetSize(30, 20) // Very narrow width
pane.Resize(30, 20) // Very narrow width
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -64,7 +64,7 @@ func TestPane_View_SmallHeight(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.TreeVM)
pane.SetSize(50, 8) // Very short height
pane.Resize(50, 8) // Very short height
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -75,7 +75,7 @@ func TestPane_View_LargeSize(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.TreeVM)
pane.SetSize(120, 40) // Large dimensions
pane.Resize(120, 40) // Large dimensions
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -92,7 +92,7 @@ func TestPane_Update_WithLayoutMsg(t *testing.T) {
updatedPane, _ := pane.Update(layoutMsg)
// Verify size was updated
view := updatedPane.(Pane).View()
view := updatedPane.(*Pane).View()
snaps.MatchSnapshot(t, view)
}
@ -101,7 +101,7 @@ func TestPane_Update_TreeNavigation(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.TreeVM)
pane.SetSize(50, 20)
pane.Resize(50, 20)
// Focus the pane
pane.Update(FocusStateMsg{Focused: true})

View file

@ -30,7 +30,7 @@
│Total Image size: 1.2 MB │
│Potential wasted space: 31.3 KB │
│Image efficiency score: 98% │
│Files > 0 KB total: 0
│Files > 0 KB total: 5
│ │
│Count Total Space Path │
│2 6.3 KB /root/example/somefile3.txt │
@ -53,7 +53,7 @@
│Total Image size: 1.2 MB │
│Potential wasted space: 31.3 KB │
│Image efficiency score: 98% │
│Files > 0 KB total: 0
│Files > 0 KB total: 5
│ │
│Count Total Space Path │
│2 6.3 KB /root/example/somefile3.txt │
@ -76,7 +76,7 @@
│Total Image size: 1.2 MB │
│Potential wasted space: 31.3 KB │
│Image efficiency score: 98% │
│Files > 0 KB total: 0
│Files > 0 KB total: 5
│ │
│Count Total Space Path │
│2 6.3 KB /root/example/so...│
@ -110,7 +110,7 @@
│Total Image size: 1.2 MB │
│Potential wasted space: 31.3 KB │
│Image efficiency score: 98% │
│Files > 0 KB total: 0
│Files > 0 KB total: 5
│ │
│Count Total Space Path │
│2 6.3 KB /root/example/somefile3.txt │
@ -153,7 +153,7 @@
│Total Image size: 1.2 MB │
│Potential wasted space: 31.3 KB │
│Image efficiency score: 98% │
│Files > 0 KB total: 0
│Files > 0 KB total: 5
│ │
│Count Total Space Path │
│2 6.3 KB /root/example/somefile3.txt │

View file

@ -11,6 +11,7 @@ import (
"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/domain"
"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"
@ -44,8 +45,8 @@ func New(analysis *image.Analysis) Pane {
return p
}
// SetSize updates the pane dimensions
func (m *Pane) SetSize(width, height int) {
// Resize updates the pane dimensions
func (m *Pane) Resize(width, height int) {
m.width = width
m.height = height
@ -69,25 +70,30 @@ func (m *Pane) SetAnalysis(analysis *image.Analysis) {
}
// Init initializes the pane
func (m Pane) Init() tea.Cmd {
func (m *Pane) Init() tea.Cmd {
m.updateContent()
return nil
}
// SetFocused sets the focus state of the pane
func (m *Pane) SetFocused(focused bool) {
m.focused = focused
}
// Update handles messages
func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Pane) Update(msg tea.Msg) (common.Pane, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case common.LayoutMsg:
// Parent sends layout info instead of calling SetSize()
// Parent sends layout info instead of calling Resize()
// Extract what we need from the message
m.SetSize(msg.LeftWidth, msg.ImageHeight)
m.Resize(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
// Parent controls focus state - use SetFocused method
m.SetFocused(msg.Focused)
return m, nil
case tea.KeyMsg:
@ -141,17 +147,17 @@ func (m *Pane) updateContent() {
func (m *Pane) generateContent() string {
width := m.width - 2 // Subtract borders
// Count files > 0 bytes
filesGreaterThanZeroKB := m.countFilesAboveZeroBytes()
// Calculate stats using domain logic (pure function, no side effects)
stats := domain.CalculateImageStats(m.analysis)
// Header with statistics
headerText := fmt.Sprintf(
"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,
stats.ImageName,
utils.FormatSize(stats.TotalSizeBytes),
utils.FormatSize(stats.WastedBytes),
stats.EfficiencyScore,
stats.FilesAboveZeroKB,
)
// Table header
@ -181,20 +187,3 @@ 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

@ -12,7 +12,7 @@ import (
func TestPane_View_NoAnalysis(t *testing.T) {
// Test with nil analysis
pane := New(nil)
pane.SetSize(80, 20)
pane.Resize(80, 20)
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -23,7 +23,7 @@ func TestPane_View_WithAnalysis(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.Analysis)
pane.SetSize(80, 20)
pane.Resize(80, 20)
// Initialize the pane
cmd := pane.Init()
@ -38,12 +38,12 @@ func TestPane_View_Focused(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.Analysis)
pane.SetSize(80, 20)
pane.Resize(80, 20)
// Send focus message
updatedPane, _ := pane.Update(FocusStateMsg{Focused: true})
view := updatedPane.(Pane).View()
view := updatedPane.(*Pane).View()
snaps.MatchSnapshot(t, view)
}
@ -52,7 +52,7 @@ func TestPane_View_SmallWidth(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.Analysis)
pane.SetSize(40, 20) // Narrow width
pane.Resize(40, 20) // Narrow width
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -63,7 +63,7 @@ func TestPane_View_SmallHeight(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.Analysis)
pane.SetSize(80, 8) // Short height
pane.Resize(80, 8) // Short height
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -74,7 +74,7 @@ func TestPane_View_LargeSize(t *testing.T) {
testData := testutils.LoadTestImage(t)
pane := New(testData.Analysis)
pane.SetSize(120, 40) // Large dimensions
pane.Resize(120, 40) // Large dimensions
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -91,6 +91,6 @@ func TestPane_Update_WithLayoutMsg(t *testing.T) {
updatedPane, _ := pane.Update(layoutMsg)
// Verify size was updated
view := updatedPane.(Pane).View()
view := updatedPane.(*Pane).View()
snaps.MatchSnapshot(t, view)
}

View file

@ -13,6 +13,7 @@ import (
"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/domain"
"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"
@ -55,7 +56,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)
statsCache []domain.FileStats // Cached statistics for each layer (calculated once)
}
// New creates a new layers pane
@ -96,7 +97,7 @@ func (m *Pane) precalculateStats() {
}
// Pre-allocate cache for all layers
m.statsCache = make([]utils.FileStats, len(m.layerVM.Layers))
m.statsCache = make([]domain.FileStats, len(m.layerVM.Layers))
// Calculate stats for each layer
for i, layer := range m.layerVM.Layers {
@ -126,12 +127,12 @@ func (m *Pane) precalculateStats() {
}
// Calculate stats ONCE per layer (heavy tree traversal)
m.statsCache[i] = utils.CalculateFileStats(treeToCompare)
m.statsCache[i] = domain.CalculateFileStats(treeToCompare)
}
}
// SetSize updates the pane dimensions
func (m *Pane) SetSize(width, height int) {
// Resize updates the pane dimensions
func (m *Pane) Resize(width, height int) {
m.width = width
m.height = height
@ -148,6 +149,11 @@ func (m *Pane) SetSize(width, height int) {
m.updateContent()
}
// SetFocused sets the focus state of the pane
func (m *Pane) SetFocused(focused bool) {
m.focused = focused
}
// SetLayerVM updates the layer viewmodel
func (m *Pane) SetLayerVM(layerVM *viewmodel.LayerSetState) {
m.layerVM = layerVM
@ -173,25 +179,25 @@ func (m *Pane) SetLayerIndex(index int) tea.Cmd {
}
// Init initializes the pane
func (m Pane) Init() tea.Cmd {
func (m *Pane) Init() tea.Cmd {
m.updateContent()
return nil
}
// Update handles messages
func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Update handles messages and returns the updated Pane
func (m *Pane) Update(msg tea.Msg) (common.Pane, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case common.LayoutMsg:
// Parent sends layout info instead of calling SetSize()
// Parent sends layout info instead of calling Resize()
// Extract what we need from the message
m.SetSize(msg.LeftWidth, msg.LayersHeight)
m.Resize(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
// Parent controls focus state - use SetFocused method
m.SetFocused(msg.Focused)
return m, nil
case tea.KeyMsg:

View file

@ -13,7 +13,7 @@ import (
func TestPane_View_EmptyState(t *testing.T) {
// Test with nil layerVM
pane := New(nil, testutils.LoadTestImage(t).Comparer)
pane.SetSize(50, 20)
pane.Resize(50, 20)
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -30,7 +30,7 @@ func TestPane_View_WithLayers(t *testing.T) {
}
pane := New(layerVM, testData.Comparer)
pane.SetSize(80, 20)
pane.Resize(80, 20)
// Initialize the pane
cmd := pane.Init()
@ -51,12 +51,12 @@ func TestPane_View_Focused(t *testing.T) {
}
pane := New(layerVM, testData.Comparer)
pane.SetSize(80, 20)
pane.Resize(80, 20)
// Send focus message
updatedPane, _ := pane.Update(FocusStateMsg{Focused: true})
view := updatedPane.(Pane).View()
view := updatedPane.(*Pane).View()
snaps.MatchSnapshot(t, view)
}
@ -71,7 +71,7 @@ func TestPane_View_SmallWidth(t *testing.T) {
}
pane := New(layerVM, testData.Comparer)
pane.SetSize(40, 20) // Narrow width
pane.Resize(40, 20) // Narrow width
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -88,7 +88,7 @@ func TestPane_View_SmallHeight(t *testing.T) {
}
pane := New(layerVM, testData.Comparer)
pane.SetSize(80, 5) // Very short height
pane.Resize(80, 5) // Very short height
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -105,7 +105,7 @@ func TestPane_View_LargeSize(t *testing.T) {
}
pane := New(layerVM, testData.Comparer)
pane.SetSize(120, 40) // Large dimensions
pane.Resize(120, 40) // Large dimensions
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -122,7 +122,7 @@ func TestPane_View_SecondLayerSelected(t *testing.T) {
}
pane := New(layerVM, testData.Comparer)
pane.SetSize(80, 20)
pane.Resize(80, 20)
view := pane.View()
snaps.MatchSnapshot(t, view)
@ -145,6 +145,6 @@ func TestPane_Update_WithLayoutMsg(t *testing.T) {
updatedPane, _ := pane.Update(layoutMsg)
// Verify size was updated
view := updatedPane.(Pane).View()
view := updatedPane.(*Pane).View()
snaps.MatchSnapshot(t, view)
}