Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 36 additions & 27 deletions cmd/dive/cli/internal/ui/v1/view/image_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@ import (
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key"
"github.com/wagoodman/dive/internal/log"
"strconv"
"strings"

"github.com/awesome-gocui/gocui"
"github.com/dustin/go-humanize"
"github.com/wagoodman/dive/dive/filetree"
)

Expand All @@ -24,7 +21,12 @@ type ImageDetails struct {
imageSize uint64
efficiency float64
inefficiencies filetree.EfficiencySlice
preRendered string
kb key.Bindings

lastRenderWidth int
lastRenderIsSelected bool
rendered bool
}

func (v *ImageDetails) Name() string {
Expand Down Expand Up @@ -80,44 +82,38 @@ func (v *ImageDetails) Setup(body, header *gocui.View) error {
// 2. the estimated wasted image space
// 3. a list of inefficient file allocations
func (v *ImageDetails) Render() error {
analysisTemplate := "%5s %12s %-s\n"
inefficiencyReport := fmt.Sprintf(format.Header(analysisTemplate), "Count", "Total Space", "Path")
if v.body == nil {
return nil
}

var wastedSpace int64
for idx := 0; idx < len(v.inefficiencies); idx++ {
data := v.inefficiencies[len(v.inefficiencies)-1-idx]
wastedSpace += data.CumulativeSize
if v.preRendered == "" {
body, err := renderImageDetailsBody(v.imageName, v.imageSize, v.efficiency, v.inefficiencies)
if err != nil {
return err
}
v.preRendered = body
}

inefficiencyReport += fmt.Sprintf(analysisTemplate, strconv.Itoa(len(data.Nodes)), humanize.Bytes(uint64(data.CumulativeSize)), data.Path)
width, _ := v.body.Size()
isSelected := v.gui.CurrentView() == v.body
if !v.shouldRender(width, isSelected) {
return nil
}

imageNameStr := fmt.Sprintf("%s %s", format.Header("Image name:"), v.imageName)
imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(v.imageSize))
efficiencyStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*v.efficiency))
wastedSpaceStr := fmt.Sprintf("%s %s", format.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace)))
v.lastRenderWidth = width
v.lastRenderIsSelected = isSelected
v.rendered = true

v.gui.Update(func(g *gocui.Gui) error {
width, _ := v.body.Size()

imageHeaderStr := format.RenderHeader("Image Details", width, v.gui.CurrentView() == v.body)

v.header.Clear()
_, err := fmt.Fprintln(v.header, imageHeaderStr)
if err != nil {
log.WithFields("error", err).Debug("unable to write to buffer")
}

var lines = []string{
imageNameStr,
imageSizeStr,
wastedSpaceStr,
efficiencyStr,
" ", // to avoid an empty line so CursorDown can work as expected
inefficiencyReport,
}

v.body.Clear()
_, err = fmt.Fprintln(v.body, strings.Join(lines, "\n"))
_, err = fmt.Fprintln(v.body, v.preRendered)
if err != nil {
log.WithFields("error", err).Debug("unable to write to buffer")
}
Expand All @@ -127,6 +123,19 @@ func (v *ImageDetails) Render() error {
return nil
}

func (v *ImageDetails) shouldRender(width int, isSelected bool) bool {
if !v.rendered {
return true
}
if v.lastRenderWidth != width {
return true
}
if v.lastRenderIsSelected != isSelected {
return true
}
return false
}

func (v *ImageDetails) OnLayoutChange() error {
if err := v.Update(); err != nil {
return err
Expand Down
56 changes: 56 additions & 0 deletions cmd/dive/cli/internal/ui/v1/view/image_details_content.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package view

import (
"fmt"
"strconv"
"strings"

"github.com/dustin/go-humanize"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format"
"github.com/wagoodman/dive/dive/filetree"
)

const imageDetailsAnalysisTemplate = "%5s %12s %-s\n"

func renderImageDetailsBody(imageName string, imageSize uint64, efficiency float64, inefficiencies filetree.EfficiencySlice) (string, error) {
var inefficiencyReport strings.Builder
inefficiencyReport.Grow(len(inefficiencies) * 64)
inefficiencyReport.WriteString(fmt.Sprintf(format.Header(imageDetailsAnalysisTemplate), "Count", "Total Space", "Path"))

var wastedSpace int64
for idx := 0; idx < len(inefficiencies); idx++ {
data := inefficiencies[len(inefficiencies)-1-idx]
wastedSpace += data.CumulativeSize

_, err := fmt.Fprintf(
&inefficiencyReport,
imageDetailsAnalysisTemplate,
strconv.Itoa(len(data.Nodes)),
humanize.Bytes(uint64(data.CumulativeSize)),
data.Path,
)
if err != nil {
return "", err
}
}

var body strings.Builder
imageNameStr := fmt.Sprintf("%s %s", format.Header("Image name:"), imageName)
imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(imageSize))
efficiencyStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*efficiency))
wastedSpaceStr := fmt.Sprintf("%s %s", format.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace)))

body.WriteString(imageNameStr)
body.WriteString("\n")
body.WriteString(imageSizeStr)
body.WriteString("\n")
body.WriteString(wastedSpaceStr)
body.WriteString("\n")
body.WriteString(efficiencyStr)
body.WriteString("\n")
body.WriteString(" ")
body.WriteString("\n")
body.WriteString(inefficiencyReport.String())

return body.String(), nil
}
121 changes: 121 additions & 0 deletions cmd/dive/cli/internal/ui/v1/view/image_details_regression_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package view

import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"runtime"
"strconv"
"testing"
"time"

"github.com/lunixbochs/vtclean"
"github.com/stretchr/testify/require"
"github.com/wagoodman/dive/dive/filetree"
)

func imageDetailsContentSourcePath(t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok, "unable to determine test file path")
return filepath.Join(filepath.Dir(thisFile), "image_details_content.go")
}

func imageDetailsContentAST(t *testing.T) *ast.File {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, imageDetailsContentSourcePath(t), nil, parser.ParseComments)
require.NoError(t, err, "unable to parse image_details_content.go")
return file
}

func funcDeclByName(file *ast.File, funcName string) *ast.FuncDecl {
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Name == nil || fn.Name.Name != funcName || fn.Recv != nil {
continue
}
return fn
}
return nil
}

// Regression guard: report construction must not use repeated string appends
// (`+=`) in loops, which can reintroduce O(n^2) behavior.
func TestRenderImageDetailsBodyAvoidsInLoopStringAddAssign(t *testing.T) {
file := imageDetailsContentAST(t)
fn := funcDeclByName(file, "renderImageDetailsBody")
require.NotNil(t, fn, "missing renderImageDetailsBody declaration")

found := false
ast.Inspect(fn.Body, func(node ast.Node) bool {
assign, ok := node.(*ast.AssignStmt)
if !ok || assign.Tok != token.ADD_ASSIGN || len(assign.Lhs) != 1 {
return true
}

lhs, ok := assign.Lhs[0].(*ast.Ident)
if ok && lhs.Name == "inefficiencyReport" {
found = true
return false
}
return true
})

require.False(t, found, "renderImageDetailsBody reintroduced inefficiencyReport += ... in-loop concatenation")
}

func TestRenderImageDetailsBodyIncludesExpectedContent(t *testing.T) {
body, err := renderImageDetailsBody(
"ghcr.io/openclaw/openclaw:test",
10*1024*1024,
0.72,
filetree.EfficiencySlice{
{Path: "/a", Nodes: []*filetree.FileNode{{}, {}, {}}, CumulativeSize: 40},
{Path: "/b", Nodes: []*filetree.FileNode{{}}, CumulativeSize: 60},
},
)
require.NoError(t, err)

clean := vtclean.Clean(body, false)
require.Contains(t, clean, "Image name:")
require.Contains(t, clean, "ghcr.io/openclaw/openclaw:test")
require.Contains(t, clean, "Total Image size:")
require.Contains(t, clean, "Potential wasted space:")
require.Contains(t, clean, "Image efficiency score:")
require.Contains(t, clean, "/a")
require.Contains(t, clean, "/b")
}

// Integration-style perf check: rendering should scale near-linearly as
// inefficiency row counts grow.
func TestRenderImageDetailsBodyScaling(t *testing.T) {
if testing.Short() {
t.Skip("skipping perf-sensitive scaling check in short mode")
}

buildDuration := func(entries, rounds int) time.Duration {
payload := make(filetree.EfficiencySlice, 0, entries)
for i := 0; i < entries; i++ {
payload = append(payload, &filetree.EfficiencyData{
Path: "/path/" + strconv.Itoa(i),
Nodes: make([]*filetree.FileNode, (i%3)+1),
CumulativeSize: int64(64 + i%1024),
})
}

start := time.Now()
for i := 0; i < rounds; i++ {
_, err := renderImageDetailsBody("img:test", 1234, 0.9, payload)
require.NoError(t, err)
}
return time.Since(start)
}

small := buildDuration(1200, 10)
large := buildDuration(2400, 10)
ratio := float64(large) / float64(small)

require.Less(t, ratio, 3.2, "expected near-linear scaling, got ratio %.2f (small=%s large=%s)", ratio, small, large)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package view

import "testing"

func TestImageDetailsShouldRenderOnFirstRender(t *testing.T) {
v := &ImageDetails{}

if !v.shouldRender(120, false) {
t.Fatalf("expected first render to be required")
}
}

func TestImageDetailsShouldRenderWhenLayoutOrSelectionChanges(t *testing.T) {
v := &ImageDetails{
rendered: true,
lastRenderWidth: 120,
lastRenderIsSelected: false,
}

if v.shouldRender(120, false) {
t.Fatalf("expected render to be skipped when state has not changed")
}

if !v.shouldRender(121, false) {
t.Fatalf("expected render when width changes")
}

if !v.shouldRender(120, true) {
t.Fatalf("expected render when selection state changes")
}
}