mirror of
https://github.com/wagoodman/dive
synced 2026-03-14 14:25:50 +01:00
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:
parent
83a648b3ef
commit
ecbad850ac
17 changed files with 637 additions and 276 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
27
cmd/dive/cli/internal/ui/v2/common/pane.go
Normal file
27
cmd/dive/cli/internal/ui/v2/common/pane.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
65
cmd/dive/cli/internal/ui/v2/domain/image_stats.go
Normal file
65
cmd/dive/cli/internal/ui/v2/domain/image_stats.go
Normal 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
|
||||
}
|
||||
223
cmd/dive/cli/internal/ui/v2/domain/image_stats_test.go
Normal file
223
cmd/dive/cli/internal/ui/v2/domain/image_stats_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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{}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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 │
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue