diff --git a/cmd/dive/cli/internal/ui/v2/app/filter.go b/cmd/dive/cli/internal/ui/v2/app/filter.go index e4bf36d..589e22f 100644 --- a/cmd/dive/cli/internal/ui/v2/app/filter.go +++ b/cmd/dive/cli/internal/ui/v2/app/filter.go @@ -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 diff --git a/cmd/dive/cli/internal/ui/v2/app/layout/engine.go b/cmd/dive/cli/internal/ui/v2/app/layout/engine.go index 461de15..d8ea7da 100644 --- a/cmd/dive/cli/internal/ui/v2/app/layout/engine.go +++ b/cmd/dive/cli/internal/ui/v2/app/layout/engine.go @@ -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 +} diff --git a/cmd/dive/cli/internal/ui/v2/app/model.go b/cmd/dive/cli/internal/ui/v2/app/model.go index 25023ba..6058c71 100644 --- a/cmd/dive/cli/internal/ui/v2/app/model.go +++ b/cmd/dive/cli/internal/ui/v2/app/model.go @@ -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) + } +} + diff --git a/cmd/dive/cli/internal/ui/v2/common/pane.go b/cmd/dive/cli/internal/ui/v2/common/pane.go new file mode 100644 index 0000000..aca2166 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/common/pane.go @@ -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) +} diff --git a/cmd/dive/cli/internal/ui/v2/components/file_stats.go b/cmd/dive/cli/internal/ui/v2/components/file_stats.go index cd572e2..52c63c3 100644 --- a/cmd/dive/cli/internal/ui/v2/components/file_stats.go +++ b/cmd/dive/cli/internal/ui/v2/components/file_stats.go @@ -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 diff --git a/cmd/dive/cli/internal/ui/v2/domain/image_stats.go b/cmd/dive/cli/internal/ui/v2/domain/image_stats.go new file mode 100644 index 0000000..7fb8578 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/domain/image_stats.go @@ -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 +} diff --git a/cmd/dive/cli/internal/ui/v2/domain/image_stats_test.go b/cmd/dive/cli/internal/ui/v2/domain/image_stats_test.go new file mode 100644 index 0000000..49825d8 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/domain/image_stats_test.go @@ -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) + }) + } +} diff --git a/cmd/dive/cli/internal/ui/v2/utils/tree_stats.go b/cmd/dive/cli/internal/ui/v2/domain/tree_stats.go similarity index 89% rename from cmd/dive/cli/internal/ui/v2/utils/tree_stats.go rename to cmd/dive/cli/internal/ui/v2/domain/tree_stats.go index 2abdfd0..5440163 100644 --- a/cmd/dive/cli/internal/ui/v2/utils/tree_stats.go +++ b/cmd/dive/cli/internal/ui/v2/domain/tree_stats.go @@ -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{} diff --git a/cmd/dive/cli/internal/ui/v2/panes/details/pane.go b/cmd/dive/cli/internal/ui/v2/panes/details/pane.go index 57df327..08f1b7e 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/details/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/details/pane.go @@ -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 diff --git a/cmd/dive/cli/internal/ui/v2/panes/details/pane_test.go b/cmd/dive/cli/internal/ui/v2/panes/details/pane_test.go index cba4948..2fb2279 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/details/pane_test.go +++ b/cmd/dive/cli/internal/ui/v2/panes/details/pane_test.go @@ -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) } diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go index 0ecf6db..f7968f8 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go @@ -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: diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane_test.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane_test.go index 29e6bee..b1aab8e 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane_test.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane_test.go @@ -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}) diff --git a/cmd/dive/cli/internal/ui/v2/panes/image/__snapshots__/pane_test.snap b/cmd/dive/cli/internal/ui/v2/panes/image/__snapshots__/pane_test.snap index 925eb16..ae09795 100755 --- a/cmd/dive/cli/internal/ui/v2/panes/image/__snapshots__/pane_test.snap +++ b/cmd/dive/cli/internal/ui/v2/panes/image/__snapshots__/pane_test.snap @@ -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 │ diff --git a/cmd/dive/cli/internal/ui/v2/panes/image/pane.go b/cmd/dive/cli/internal/ui/v2/panes/image/pane.go index 04fc2f3..191453f 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/image/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/image/pane.go @@ -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 -} diff --git a/cmd/dive/cli/internal/ui/v2/panes/image/pane_test.go b/cmd/dive/cli/internal/ui/v2/panes/image/pane_test.go index e09eca0..933905d 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/image/pane_test.go +++ b/cmd/dive/cli/internal/ui/v2/panes/image/pane_test.go @@ -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) } diff --git a/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go b/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go index 9bdd118..b618fe0 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go @@ -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: diff --git a/cmd/dive/cli/internal/ui/v2/panes/layers/pane_test.go b/cmd/dive/cli/internal/ui/v2/panes/layers/pane_test.go index 776f722..ca3f639 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/layers/pane_test.go +++ b/cmd/dive/cli/internal/ui/v2/panes/layers/pane_test.go @@ -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) }