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
32 changes: 4 additions & 28 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 Down Expand Up @@ -80,22 +77,11 @@ 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")

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

inefficiencyReport += fmt.Sprintf(analysisTemplate, strconv.Itoa(len(data.Nodes)), humanize.Bytes(uint64(data.CumulativeSize)), data.Path)
body, err := renderImageDetailsBody(v.imageName, v.imageSize, v.efficiency, v.inefficiencies)
if err != nil {
return err
}

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.gui.Update(func(g *gocui.Gui) error {
width, _ := v.body.Size()

Expand All @@ -106,18 +92,8 @@ func (v *ImageDetails) Render() error {
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, body)
if err != nil {
log.WithFields("error", err).Debug("unable to write to buffer")
}
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)
}