chore: refactor command structure (#587)

* refactor cli harness

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* use single configuration for ui

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* remove remaining viper rules

* add basic CLI tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* separate cmd and api concerns for keybindings

* plumb through context for future logging

* port to using internal logger

* internalize non-analysis path

* encapsulate ui with clio

* merge runtime into cmd + add adapter package

* support legacy config shapes

* improve testing around formatting

* fix log-ui interactions

* fix linting and update test snapshots

* fix initialization of tree viewmodel

* indent files in report

* fix build

* setup qemu and buildx in release workflow

* show formatted output in CI

* add cli tests for source flag

* add default ci config cli test

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman 2025-04-08 12:19:18 -04:00 committed by GitHub
commit 788fcd3834
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
156 changed files with 13845 additions and 6991 deletions

View file

@ -4,7 +4,7 @@
/dist
!/dist/dive_linux_amd64
/ui
/utils
/internal/utils
/image
/cmd
/build

View file

@ -105,6 +105,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Tag release
run: |
git tag ${{ github.event.inputs.version }}

5
.gitignore vendored
View file

@ -1,3 +1,6 @@
# app configs
.dive.yaml
# misc
/.image
*.log
@ -15,6 +18,8 @@ VERSION
/.tool
/.mise.toml
/.task
/go.work
/go.work.sum
# builds
/dist

View file

@ -15,6 +15,7 @@ env:
builds:
- binary: dive
dir: ./cmd/dive
env:
- CGO_ENABLED=0
goos:
@ -25,7 +26,14 @@ builds:
- amd64
- arm64
- ppc64le
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.buildTime={{.Date}}
ldflags:
-w
-s
-extldflags '-static'
-X main.version={{.Version}}
-X main.gitCommit={{.Commit}}
-X main.buildDate={{.Date}}
-X main.gitDescription={{.Summary}}
brews:
- repository:
@ -59,6 +67,7 @@ dockers:
use: buildx
goarch: amd64
image_templates:
- docker.io/wagoodman/dive:latest
- docker.io/wagoodman/dive:v{{.Version}}-amd64
build_flag_templates:
- "--build-arg=DOCKER_CLI_VERSION={{.Env.DOCKER_CLI_VERSION}}"

View file

@ -61,6 +61,7 @@ tasks:
desc: Run all levels of test
cmds:
- task: unit
- task: cli
## Bootstrap tasks #################################
@ -172,15 +173,19 @@ tasks:
- tmpdir
vars:
TEST_PKGS:
sh: "go list ./... | tr '\n' ' '"
sh: "go list ./... | grep -v '^github.com/wagoodman/dive/cmd/dive/cli$' | tr '\n' ' '"
# unit test coverage threshold (in % coverage)
COVERAGE_THRESHOLD: 30
COVERAGE_THRESHOLD: 25
cmds:
- "go test -coverprofile {{ .TMP_DIR }}/unit-coverage-details.txt {{ .TEST_PKGS }}"
- cmd: ".github/scripts/coverage.py {{ .COVERAGE_THRESHOLD }} {{ .TMP_DIR }}/unit-coverage-details.txt"
silent: true
cli:
desc: Run CLI tests
cmds:
- "go test github.com/wagoodman/dive/cmd/dive/cli -v"
## Acceptance tests #################################
@ -198,6 +203,7 @@ tasks:
docker run \
--rm \
-t \
--env CLICOLOR_FORCE=true \
-v /var/run/docker.sock:/var/run/docker.sock \
'docker.io/wagoodman/dive:latest' \
'{{ .TEST_IMAGE }}' \
@ -208,9 +214,11 @@ tasks:
cmds:
- |
docker run \
--platform linux/amd64 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /${PWD}:/src \
-w /src \
--env CLICOLOR_FORCE=true \
ubuntu:latest \
/bin/bash -x -c "\
apt update && \
@ -229,9 +237,11 @@ tasks:
cmds:
- |
docker run \
--platform linux/amd64 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /${PWD}:/src \
-w /src \
--env CLICOLOR_FORCE=true \
fedora:latest \
/bin/bash -x -c "\
curl -L 'https://download.docker.com/linux/static/stable/x86_64/docker-{{ .DOCKER_CLI_VERSION }}.tgz' | \
@ -321,6 +331,7 @@ tasks:
deps: [tools, tmpdir]
sources:
- "**/*.go"
- ".goreleaser.yaml"
method: checksum
cmds:
- silent: true

View file

@ -1,73 +0,0 @@
package cmd
import (
"fmt"
"os"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/wagoodman/dive/dive"
"github.com/wagoodman/dive/runtime"
)
// doAnalyzeCmd takes a docker image tag, digest, or id and displays the
// image analysis to the screen
func doAnalyzeCmd(cmd *cobra.Command, args []string) {
if len(args) == 0 {
printVersionFlag, err := cmd.PersistentFlags().GetBool("version")
if err == nil && printVersionFlag {
printVersion(cmd, args)
return
}
fmt.Println("No image argument given")
os.Exit(1)
}
userImage := args[0]
if userImage == "" {
fmt.Println("No image argument given")
os.Exit(1)
}
initLogging()
isCi, ciConfig, err := configureCi()
if err != nil {
fmt.Printf("ci configuration error: %v\n", err)
os.Exit(1)
}
var sourceType dive.ImageSource
var imageStr string
sourceType, imageStr = dive.DeriveImageSource(userImage)
if sourceType == dive.SourceUnknown {
sourceStr := viper.GetString("source")
sourceType = dive.ParseImageSource(sourceStr)
if sourceType == dive.SourceUnknown {
fmt.Printf("unable to determine image source: %v\n", sourceStr)
os.Exit(1)
}
imageStr = userImage
}
ignoreErrors, err := cmd.PersistentFlags().GetBool("ignore-errors")
if err != nil {
logrus.Error("unable to get 'ignore-errors' option:", err)
}
runtime.Run(runtime.Options{
Ci: isCi,
Source: sourceType,
Image: imageStr,
ExportFile: exportFile,
CiConfig: ciConfig,
IgnoreErrors: viper.GetBool("ignore-errors") || ignoreErrors,
})
}

View file

@ -1,38 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/wagoodman/dive/dive"
"github.com/wagoodman/dive/runtime"
)
// buildCmd represents the build command
var buildCmd = &cobra.Command{
Use: "build [any valid `docker build` arguments]",
Short: "Builds and analyzes a docker image from a Dockerfile (this is a thin wrapper for the `docker build` command).",
DisableFlagParsing: true,
Run: doBuildCmd,
}
func init() {
rootCmd.AddCommand(buildCmd)
}
// doBuildCmd implements the steps taken for the build command
func doBuildCmd(cmd *cobra.Command, args []string) {
initLogging()
// there is no cli options allowed, only config can be supplied
// todo: allow for an engine flag to be passed to dive but not the container engine
engine := viper.GetString("container-engine")
runtime.Run(runtime.Options{
Ci: isCi,
Source: dive.ParseImageSource(engine),
BuildArgs: args,
ExportFile: exportFile,
CiConfig: ciConfig,
})
}

View file

@ -1,37 +0,0 @@
package cmd
import (
"bytes"
"fmt"
"os"
"strconv"
"github.com/spf13/viper"
)
func configureCi() (bool, *viper.Viper, error) {
isCiFromEnv, _ := strconv.ParseBool(os.Getenv("CI"))
isCi = isCi || isCiFromEnv
if isCi {
ciConfig.SetConfigType("yaml")
if _, err := os.Stat(ciConfigFile); !os.IsNotExist(err) {
fmt.Printf(" Using CI config: %s\n", ciConfigFile)
fileBytes, err := os.ReadFile(ciConfigFile)
if err != nil {
return isCi, nil, err
}
err = ciConfig.ReadConfig(bytes.NewBuffer(fileBytes))
if err != nil {
return isCi, nil, err
}
} else {
fmt.Println(" Using default CI config")
}
}
return isCi, ciConfig, nil
}

53
cmd/dive/cli/cli.go Normal file
View file

@ -0,0 +1,53 @@
package cli
import (
"github.com/anchore/clio"
"github.com/spf13/cobra"
"github.com/wagoodman/dive/cmd/dive/cli/internal/command"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui"
"github.com/wagoodman/dive/internal/bus"
"github.com/wagoodman/dive/internal/log"
)
func Application(id clio.Identification) clio.Application {
app, _ := create(id)
return app
}
func Command(id clio.Identification) *cobra.Command {
_, cmd := create(id)
return cmd
}
func create(id clio.Identification) (clio.Application, *cobra.Command) {
clioCfg := clio.NewSetupConfig(id).
WithGlobalConfigFlag(). // add persistent -c <path> for reading an application config from
WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config
WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text
WithUI(ui.None()).
WithInitializers(
func(state *clio.State) error {
bus.Set(state.Bus)
log.Set(state.Logger)
//stereoscope.SetBus(state.Bus)
//stereoscope.SetLogger(state.Logger)
return nil
},
)
//WithPostRuns(func(_ *clio.State, _ error) {
// stereoscope.Cleanup()
//})
app := clio.New(*clioCfg)
rootCmd := command.Root(app)
rootCmd.AddCommand(
clio.VersionCommand(id),
clio.ConfigCommand(app, nil),
command.Build(app),
)
return app, rootCmd
}

View file

@ -0,0 +1,91 @@
package cli
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"regexp"
"testing"
)
func Test_Build_Dockerfile(t *testing.T) {
t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-pass.yaml")
t.Run("implicit dockerfile", func(t *testing.T) {
rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile")
stdout := Capture().WithStdout().WithSuppress().Run(t, func() {
require.NoError(t, rootCmd.Execute())
})
snaps.MatchSnapshot(t, stdout)
})
t.Run("explicit file flag", func(t *testing.T) {
rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile -f testdata/image-multi-layer-dockerfile/Dockerfile")
stdout := Capture().WithStdout().WithSuppress().Run(t, func() {
require.NoError(t, rootCmd.Execute())
})
snaps.MatchSnapshot(t, stdout)
})
}
func Test_Build_Containerfile(t *testing.T) {
t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-containerfile/dive-pass.yaml")
t.Run("implicit containerfile", func(t *testing.T) {
rootCmd := getTestCommand(t, "build testdata/image-multi-layer-containerfile")
stdout := Capture().WithStdout().WithSuppress().Run(t, func() {
require.NoError(t, rootCmd.Execute())
})
snaps.MatchSnapshot(t, stdout)
})
t.Run("explicit file flag", func(t *testing.T) {
rootCmd := getTestCommand(t, "build testdata/image-multi-layer-containerfile -f testdata/image-multi-layer-containerfile/Containerfile")
stdout := Capture().WithStdout().WithSuppress().Run(t, func() {
require.NoError(t, rootCmd.Execute())
})
snaps.MatchSnapshot(t, stdout)
})
}
func Test_Build_CI_gate_fail(t *testing.T) {
t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-fail.yaml")
rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile")
stdout := Capture().WithStdout().WithSuppress().Run(t, func() {
// failing gate should result in a non-zero exit code
require.Error(t, rootCmd.Execute())
})
snaps.MatchSnapshot(t, stdout)
}
func Test_BuildFailure(t *testing.T) {
t.Run("nonexistent directory", func(t *testing.T) {
rootCmd := getTestCommand(t, "build ./path/does/not/exist")
combined := Capture().WithStdout().WithStderr().Run(t, func() {
require.ErrorContains(t, rootCmd.Execute(), "could not find Containerfile or Dockerfile")
})
assert.Contains(t, combined, "Building image")
snaps.MatchSnapshot(t, combined)
})
t.Run("invalid dockerfile", func(t *testing.T) {
rootCmd := getTestCommand(t, "build ./testdata/invalid")
combined := Capture().WithStdout().WithStderr().WithSuppress().Run(t, func() {
require.ErrorContains(t, rootCmd.Execute(), "cannot build image: exit status 1")
})
assert.Contains(t, combined, "Building image")
// ensure we're passing through docker feedback
assert.Contains(t, combined, "unknown instruction: INVALID")
// replace anything starting with "docker-desktop://", like "docker-desktop://dashboard/build/desktop-linux/desktop-linux/ujdmhgkwo0sqqpopsnum3xakd"
combined = regexp.MustCompile("docker-desktop://[^ ]+").ReplaceAllString(combined, "docker-desktop://<redacted>")
snaps.MatchSnapshot(t, combined)
})
}

View file

@ -0,0 +1,51 @@
package cli
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func Test_CI_DefaultCIConfig(t *testing.T) {
// this lets the test harness to unset any DIVE_CONFIG env var
t.Setenv("DIVE_CONFIG", "-")
rootCmd := getTestCommand(t, repoPath(t, ".data/test-docker-image.tar")+" -vv")
cd(t, "testdata/default-ci-config")
combined := Capture().WithStdout().WithStderr().Run(t, func() {
// failing gate should result in a non-zero exit code
require.Error(t, rootCmd.Execute())
})
assert.Contains(t, combined, "lowest-efficiency: \"0.96\"", "missing lowest-efficiency rule")
assert.Contains(t, combined, "highest-wasted-bytes: 19Mb", "missing highest-wasted-bytes rule")
assert.Contains(t, combined, "highest-user-wasted-percent: \"0.6\"", "missing highest-user-wasted-percent rule")
snaps.MatchSnapshot(t, combined)
}
func Test_CI_Fail(t *testing.T) {
t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-fail.yaml")
rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile")
stdout := Capture().WithStdout().WithSuppress().Run(t, func() {
// failing gate should result in a non-zero exit code
require.Error(t, rootCmd.Execute())
})
snaps.MatchSnapshot(t, stdout)
}
func Test_CI_LegacyRules(t *testing.T) {
t.Setenv("DIVE_CONFIG", "./testdata/config/dive-ci-legacy.yaml")
rootCmd := getTestCommand(t, "config --load")
all := Capture().All().Run(t, func() {
require.NoError(t, rootCmd.Execute())
})
// this proves that we can load the legacy rules and map them to the standard rules
assert.Contains(t, all, "lowest-efficiency: '0.95'", "missing lowest-efficiency legacy rule")
assert.Contains(t, all, "highest-wasted-bytes: '20MB'", "missing highest-wasted-bytes legacy rule")
assert.Contains(t, all, "highest-user-wasted-percent: '0.2'", "missing highest-user-wasted-percent legacy rule")
}

View file

@ -0,0 +1,17 @@
package cli
import (
"github.com/stretchr/testify/require"
"testing"
)
func Test_Config(t *testing.T) {
t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-pass.yaml")
rootCmd := getTestCommand(t, "config --load")
all := Capture().All().Run(t, func() {
require.NoError(t, rootCmd.Execute())
})
snaps.MatchSnapshot(t, all)
}

View file

@ -0,0 +1,29 @@
package cli
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
)
func Test_JsonOutput(t *testing.T) {
t.Run("json output", func(t *testing.T) {
dest := t.TempDir()
file := filepath.Join(dest, "output.json")
rootCmd := getTestCommand(t, "busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f --json "+file)
combined := Capture().WithStdout().WithStderr().Run(t, func() {
require.NoError(t, rootCmd.Execute())
})
assert.Contains(t, combined, "Exporting details")
assert.Contains(t, combined, "file")
contents, err := os.ReadFile(file)
require.NoError(t, err)
snaps.MatchJSON(t, contents)
})
}

View file

@ -0,0 +1,105 @@
package cli
import (
"fmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
"os/exec"
"testing"
)
func Test_LoadImage(t *testing.T) {
image := "busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f"
archive := repoPath(t, ".data/test-docker-image.tar")
t.Run("from docker engine", func(t *testing.T) {
runWithCombinedOutput(t, fmt.Sprintf("docker://%s", image))
})
t.Run("from docker engine (flag)", func(t *testing.T) {
runWithCombinedOutput(t, fmt.Sprintf("--source docker %s", image))
})
t.Run("from podman engine", func(t *testing.T) {
if _, err := exec.LookPath("podman"); err != nil {
t.Skip("podman not installed, skipping test")
}
// pull the image from podman first
require.NoError(t, exec.Command("podman", "pull", image).Run())
runWithCombinedOutput(t, fmt.Sprintf("podman://%s", image))
})
t.Run("from podman engine (flag)", func(t *testing.T) {
if _, err := exec.LookPath("podman"); err != nil {
t.Skip("podman not installed, skipping test")
}
// pull the image from podman first
require.NoError(t, exec.Command("podman", "pull", image).Run())
runWithCombinedOutput(t, fmt.Sprintf("--source podman %s", image))
})
t.Run("from archive", func(t *testing.T) {
runWithCombinedOutput(t, fmt.Sprintf("docker-archive://%s", archive))
})
t.Run("from archive (flag)", func(t *testing.T) {
runWithCombinedOutput(t, fmt.Sprintf("--source docker-archive %s", archive))
})
}
func runWithCombinedOutput(t testing.TB, cmd string) {
t.Helper()
rootCmd := getTestCommand(t, cmd)
combined := Capture().WithStdout().WithStderr().Run(t, func() {
require.NoError(t, rootCmd.Execute())
})
assertLoadOutput(t, combined)
}
func assertLoadOutput(t testing.TB, combined string) {
t.Helper()
assert.Contains(t, combined, "Loading image")
assert.Contains(t, combined, "Analyzing image")
assert.Contains(t, combined, "Evaluating image")
snaps.MatchSnapshot(t, combined)
}
func Test_FetchFailure(t *testing.T) {
t.Run("nonexistent image", func(t *testing.T) {
rootCmd := getTestCommand(t, "docker:wagoodman/nonexistent/image:tag")
combined := Capture().WithStdout().WithStderr().Run(t, func() {
require.ErrorContains(t, rootCmd.Execute(), "cannot load image: Error response from daemon: invalid reference format")
})
assert.Contains(t, combined, "Loading image")
snaps.MatchSnapshot(t, combined)
})
t.Run("invalid image name", func(t *testing.T) {
rootCmd := getTestCommand(t, "docker:///wagoodman/invalid:image:format")
combined := Capture().WithStdout().WithStderr().Run(t, func() {
require.ErrorContains(t, rootCmd.Execute(), "cannot load image: Error response from daemon: invalid reference format")
})
assert.Contains(t, combined, "Loading image")
snaps.MatchSnapshot(t, combined)
})
}
func cd(t testing.TB, to string) {
t.Helper()
from, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(to))
t.Cleanup(func() {
require.NoError(t, os.Chdir(from))
})
}

175
cmd/dive/cli/cli_test.go Normal file
View file

@ -0,0 +1,175 @@
package cli
import (
"bytes"
"flag"
"github.com/anchore/clio"
"github.com/charmbracelet/lipgloss"
snapsPkg "github.com/gkampitakis/go-snaps/snaps"
"github.com/google/shlex"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var (
updateSnapshot = flag.Bool("update", false, "update any test snapshots")
snaps *snapsPkg.Config
repoRootCache atomic.String
)
func TestMain(m *testing.M) {
// flags are not parsed until after test.Main is called...
flag.Parse()
os.Unsetenv("DIVE_CONFIG")
// disable colors
lipgloss.SetColorProfile(termenv.Ascii)
snaps = snapsPkg.WithConfig(
snapsPkg.Update(*updateSnapshot),
snapsPkg.Dir("testdata/snapshots"),
)
v := m.Run()
snapsPkg.Clean(m)
os.Exit(v)
}
func TestUpdateSnapshotDisabled(t *testing.T) {
require.False(t, *updateSnapshot, "update snapshot flag should be disabled")
}
func repoPath(t testing.TB, path string) string {
t.Helper()
root := repoRoot(t)
return filepath.Join(root, path)
}
func repoRoot(t testing.TB) string {
val := repoRootCache.Load()
if val != "" {
return val
}
t.Helper()
// use git to find the root of the repo
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
t.Fatalf("failed to get repo root: %v", err)
}
val = strings.TrimSpace(string(out))
repoRootCache.Store(val)
return val
}
func getTestCommand(t testing.TB, cmd string) *cobra.Command {
switch os.Getenv("DIVE_CONFIG") {
case "":
t.Setenv("DIVE_CONFIG", "./testdata/dive-enable-ci.yaml")
case "-":
t.Setenv("DIVE_CONFIG", "")
}
// need basic output to logger for testing...
//l, err := logrus.New(logrus.DefaultConfig())
//require.NoError(t, err)
//log.Set(l)
// get the root command
c := Command(clio.Identification{
Name: "dive",
Version: "testing",
})
args, err := shlex.Split(cmd)
require.NoError(t, err, "failed to parse command line %q", cmd)
c.SetArgs(args)
return c
}
type capturer struct {
stdout bool
stderr bool
suppress bool
}
func Capture() *capturer {
return &capturer{}
}
func (c *capturer) WithSuppress() *capturer {
c.suppress = true
return c
}
func (c *capturer) All() *capturer {
c.stdout = true
c.stderr = true
return c
}
func (c *capturer) WithStdout() *capturer {
c.stdout = true
return c
}
func (c *capturer) WithStderr() *capturer {
c.stderr = true
return c
}
func (c *capturer) Run(t testing.TB, f func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
panic(err)
}
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
panic(err)
}
defer devNull.Close()
oldStdout := os.Stdout
oldStderr := os.Stderr
if c.stdout {
os.Stdout = w
} else if c.suppress {
os.Stdout = devNull
}
if c.stderr {
os.Stderr = w
} else if c.suppress {
os.Stderr = devNull
}
defer func() {
os.Stdout = oldStdout
os.Stderr = oldStderr
}()
f()
require.NoError(t, w.Close())
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
require.NoError(t, err)
return buf.String()
}

View file

@ -0,0 +1,68 @@
package adapter
import (
"context"
"fmt"
"github.com/dustin/go-humanize"
"github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/internal/bus"
"github.com/wagoodman/dive/internal/bus/event/payload"
"github.com/wagoodman/dive/internal/log"
)
type Analyzer interface {
Analyze(ctx context.Context, img *image.Image) (*image.Analysis, error)
}
type analysisActionObserver struct {
Analyzer func(context.Context, *image.Image) (*image.Analysis, error)
}
func NewAnalyzer() Analyzer {
return analysisActionObserver{
Analyzer: image.Analyze,
}
}
func (a analysisActionObserver) Analyze(ctx context.Context, img *image.Image) (*image.Analysis, error) {
log.WithFields("image", img.Request).Infof("analyzing")
layers := len(img.Layers)
var files int
var fileSize uint64
for _, layer := range img.Layers {
files += layer.Tree.Size
fileSize += layer.Tree.FileSize
}
fileSizeStr := humanize.Bytes(fileSize)
filesStr := humanize.Comma(int64(files))
log.Debugf("├── layers: %d", layers)
log.Debugf("├── files: %s", filesStr)
log.Debugf("└── file size: %s", fileSizeStr)
mon := bus.StartTask(payload.GenericTask{
Title: payload.Title{
Default: "Analyzing image",
WhileRunning: "Analyzing image",
OnSuccess: "Analyzed image",
},
HideOnSuccess: false,
HideStageOnSuccess: false,
ID: img.Request,
Context: fmt.Sprintf("[layers:%d files:%s size:%s]", layers, filesStr, fileSizeStr),
})
analysis, err := a.Analyzer(ctx, img)
if err != nil {
mon.SetError(err)
} else {
mon.SetCompleted()
}
if err == nil && analysis == nil {
err = fmt.Errorf("no results returned")
}
return analysis, err
}

View file

@ -0,0 +1,48 @@
package adapter
import (
"context"
"fmt"
"github.com/wagoodman/dive/cmd/dive/cli/internal/command/ci"
"github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/internal/bus"
"github.com/wagoodman/dive/internal/bus/event/payload"
"github.com/wagoodman/dive/internal/log"
)
type Evaluator interface {
Evaluate(ctx context.Context, analysis *image.Analysis) ci.Evaluation
}
type evaluationActionObserver struct {
ci.Evaluator
}
func NewEvaluator(rules []ci.Rule) Evaluator {
return evaluationActionObserver{
Evaluator: ci.NewEvaluator(rules),
}
}
func (c evaluationActionObserver) Evaluate(ctx context.Context, analysis *image.Analysis) ci.Evaluation {
log.WithFields("image", analysis.Image).Infof("evaluating image")
mon := bus.StartTask(payload.GenericTask{
Title: payload.Title{
Default: "Evaluating image",
WhileRunning: "Evaluating image",
OnSuccess: "Evaluated image",
},
HideOnSuccess: false,
HideStageOnSuccess: false,
ID: analysis.Image,
Context: fmt.Sprintf("[rules: %d]", len(c.Rules)),
})
eval := c.Evaluator.Evaluate(ctx, analysis)
if eval.Pass {
mon.SetCompleted()
} else {
mon.SetError(fmt.Errorf("failed evaluation"))
}
bus.Report(eval.Report)
return eval
}

View file

@ -0,0 +1,63 @@
package adapter
import (
"context"
"fmt"
"github.com/spf13/afero"
"github.com/wagoodman/dive/cmd/dive/cli/internal/command/export"
"github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/internal/bus"
"github.com/wagoodman/dive/internal/bus/event/payload"
"github.com/wagoodman/dive/internal/log"
"os"
)
type Exporter interface {
ExportTo(ctx context.Context, img *image.Analysis, path string) error
}
type jsonExporter struct {
filesystem afero.Fs
}
func NewExporter(fs afero.Fs) Exporter {
return &jsonExporter{
filesystem: fs,
}
}
func (e *jsonExporter) ExportTo(ctx context.Context, analysis *image.Analysis, path string) error {
log.WithFields("path", path).Infof("exporting analysis")
mon := bus.StartTask(payload.GenericTask{
Title: payload.Title{
Default: "Exporting details",
WhileRunning: "Exporting details",
OnSuccess: "Exported details",
},
HideOnSuccess: false,
HideStageOnSuccess: false,
ID: analysis.Image,
Context: fmt.Sprintf("[file: %s]", path),
})
bytes, err := export.NewExport(analysis).Marshal()
if err != nil {
mon.SetError(err)
return fmt.Errorf("cannot marshal export payload: %w", err)
} else {
mon.SetCompleted()
}
file, err := e.filesystem.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("cannot open export file: %w", err)
}
defer file.Close()
_, err = file.Write(bytes)
if err != nil {
return fmt.Errorf("cannot write to export file: %w", err)
}
return nil
}

View file

@ -0,0 +1,91 @@
package adapter
import (
"context"
"github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/internal/bus"
"github.com/wagoodman/dive/internal/bus/event/payload"
"github.com/wagoodman/dive/internal/log"
"strings"
"time"
)
type imageActionObserver struct {
image.Resolver
}
func ImageResolver(resolver image.Resolver) image.Resolver {
return imageActionObserver{
Resolver: resolver,
}
}
func (i imageActionObserver) Build(ctx context.Context, options []string) (*image.Image, error) {
log.Info("building image")
log.Debugf("└── %s", strings.Join(options, " "))
mon := bus.StartTask(payload.GenericTask{
Title: payload.Title{
Default: "Building image",
WhileRunning: "Building image",
OnSuccess: "Built image",
},
HideOnSuccess: false,
HideStageOnSuccess: false,
Context: "... " + strings.Join(options, " "),
})
ctx = payload.SetGenericProgressToContext(ctx, mon)
img, err := i.Resolver.Build(ctx, options)
if err != nil {
mon.SetError(err)
} else {
mon.SetCompleted()
}
return img, err
}
func (i imageActionObserver) Fetch(ctx context.Context, id string) (*image.Image, error) {
log.WithFields("image", id).Info("fetching")
log.Debugf("└── resolver: %s", i.Resolver.Name())
ctx, cancel := context.WithCancel(ctx)
defer cancel()
mon := bus.StartTask(payload.GenericTask{
Title: payload.Title{
Default: "Loading image",
WhileRunning: "Loading image",
OnSuccess: "Fetched image",
},
HideOnSuccess: false,
HideStageOnSuccess: false,
ID: id,
Context: id,
})
ctx = payload.SetGenericProgressToContext(ctx, mon)
go func() {
// in 5 seconds if the context is not cancelled, log the message
select { // nolint:gosimple
case <-time.After(3 * time.Second):
if ctx.Err() == nil {
bus.Notify(" • this can take a while for large images...")
mon.AtomicStage.Set("(this can take a while for large images)")
// TODO: default level should be error for this to work when using the UI
//log.Warn("this can take a while for large images")
}
}
}()
img, err := i.Resolver.Fetch(ctx, id)
if err != nil {
mon.SetError(err)
} else {
mon.SetCompleted()
}
return img, err
}

View file

@ -0,0 +1,46 @@
package command
import (
"fmt"
"github.com/anchore/clio"
"github.com/spf13/cobra"
"github.com/wagoodman/dive/cmd/dive/cli/internal/command/adapter"
"github.com/wagoodman/dive/cmd/dive/cli/internal/options"
"github.com/wagoodman/dive/dive"
)
type buildOptions struct {
options.Application `yaml:",inline" mapstructure:",squash"`
// reserved for future use of build-only flags
}
func Build(app clio.Application) *cobra.Command {
opts := &buildOptions{
Application: options.DefaultApplication(),
}
return app.SetupCommand(&cobra.Command{
Use: "build [any valid `docker build` arguments]",
Short: "Builds and analyzes a docker image from a Dockerfile (this is a thin wrapper for the `docker build` command).",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
if err := setUI(app, opts.Application); err != nil {
return fmt.Errorf("failed to set UI: %w", err)
}
resolver, err := dive.GetImageResolver(opts.Analysis.Source)
if err != nil {
return fmt.Errorf("cannot determine image provider for build: %w", err)
}
ctx := cmd.Context()
img, err := adapter.ImageResolver(resolver).Build(ctx, args)
if err != nil {
return fmt.Errorf("cannot build image: %w", err)
}
return run(cmd.Context(), opts.Application, img, resolver)
},
}, opts)
}

View file

@ -0,0 +1,308 @@
package ci
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"golang.org/x/net/context"
"sort"
"strconv"
"strings"
"github.com/dustin/go-humanize"
"github.com/wagoodman/dive/dive/image"
)
type Evaluation struct {
Report string
Pass bool
}
type Evaluator struct {
Rules []Rule
Results map[string]RuleResult
Tally ResultTally
Pass bool
Misconfigured bool
InefficientFiles []ReferenceFile
format format
}
type format struct {
Title lipgloss.Style
Success lipgloss.Style
Warning lipgloss.Style
Disabled lipgloss.Style
Failure lipgloss.Style
TableHeader lipgloss.Style
Label lipgloss.Style
Aux lipgloss.Style
Value lipgloss.Style
}
type ResultTally struct {
Pass int
Fail int
Skip int
Warn int
Total int
}
type ReferenceFile struct {
References int `json:"count"`
SizeBytes uint64 `json:"sizeBytes"`
Path string `json:"file"`
}
func NewEvaluator(rules []Rule) Evaluator {
return Evaluator{
Rules: rules,
Results: make(map[string]RuleResult),
Pass: true,
format: format{
Title: lipgloss.NewStyle().Bold(true),
Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")),
Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
Disabled: lipgloss.NewStyle().Faint(true),
Failure: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true),
TableHeader: lipgloss.NewStyle().Bold(true),
Label: lipgloss.NewStyle().Width(18),
Aux: lipgloss.NewStyle().Faint(true),
Value: lipgloss.NewStyle(),
},
}
}
func (e Evaluator) isRuleEnabled(rule Rule) bool {
return rule.Configuration() != "disabled"
}
func (e Evaluator) Evaluate(ctx context.Context, analysis *image.Analysis) Evaluation {
for _, rule := range e.Rules {
if !e.isRuleEnabled(rule) {
e.Results[rule.Key()] = RuleResult{
status: RuleConfigured,
message: "rule disabled",
}
continue
}
e.Results[rule.Key()] = RuleResult{
status: RuleConfigured,
message: "test",
}
}
// capture inefficient files
for idx := 0; idx < len(analysis.Inefficiencies); idx++ {
fileData := analysis.Inefficiencies[len(analysis.Inefficiencies)-1-idx]
e.InefficientFiles = append(e.InefficientFiles, ReferenceFile{
References: len(fileData.Nodes),
SizeBytes: uint64(fileData.CumulativeSize),
Path: fileData.Path,
})
}
// evaluate results against the configured CI rules
for _, rule := range e.Rules {
if !e.isRuleEnabled(rule) {
e.Results[rule.Key()] = RuleResult{
status: RuleDisabled,
message: "disabled",
}
continue
}
status, message := rule.Evaluate(analysis)
if value, exists := e.Results[rule.Key()]; exists && value.status != RuleConfigured && value.status != RuleMisconfigured {
panic(fmt.Errorf("CI rule result recorded twice: %s", rule.Key()))
}
if status == RuleFailed {
e.Pass = false
}
if message == "" {
message = rule.Configuration()
}
e.Results[rule.Key()] = RuleResult{
status: status,
message: message,
}
}
e.Tally.Total = len(e.Results)
for rule, result := range e.Results {
switch result.status {
case RulePassed:
e.Tally.Pass++
case RuleFailed:
e.Tally.Fail++
case RuleWarning:
e.Tally.Warn++
case RuleDisabled:
e.Tally.Skip++
default:
panic(fmt.Errorf("unknown test status (rule='%v'): %v", rule, result.status))
}
}
return Evaluation{
Report: e.report(analysis),
Pass: e.Pass,
}
}
func (e Evaluator) report(analysis *image.Analysis) string {
sections := []string{
e.renderAnalysisSection(analysis),
e.renderInefficientFilesSection(analysis),
e.renderEvaluationSection(),
}
return strings.Join(sections, "\n\n")
}
func (e Evaluator) renderAnalysisSection(analysis *image.Analysis) string {
wastedByteStr := ""
userWastedPercent := "0 %"
if analysis.WastedBytes > 0 {
wastedByteStr = fmt.Sprintf("(%s)", humanize.Bytes(analysis.WastedBytes))
userWastedPercent = fmt.Sprintf("%.2f %%", analysis.WastedUserPercent*100)
}
title := e.format.Title.Render("Analysis:")
rows := []string{
formatKeyValue(e.format, "efficiency", fmt.Sprintf("%.2f %%", analysis.Efficiency*100)),
formatKeyValue(e.format, "wastedBytes", fmt.Sprintf("%d bytes %s", analysis.WastedBytes, wastedByteStr)),
formatKeyValue(e.format, "userWastedPercent", userWastedPercent),
}
return title + "\n" + strings.Join(rows, "\n")
}
func (e Evaluator) renderInefficientFilesSection(analysis *image.Analysis) string {
title := e.format.Title.Render("Inefficient Files:")
if len(analysis.Inefficiencies) == 0 {
return title + " (None)"
}
header := e.format.TableHeader.Render(
fmt.Sprintf(" %-5s %-12s %-s", "Count", "Wasted Space", "File Path"),
)
rows := []string{header}
for _, file := range e.InefficientFiles {
row := fmt.Sprintf(" %-5s %-12s %-s",
strconv.Itoa(file.References),
humanize.Bytes(file.SizeBytes),
file.Path,
)
rows = append(rows, row)
}
return title + "\n" + strings.Join(rows, "\n")
}
func (e Evaluator) renderEvaluationSection() string {
title := e.format.Title.Render("Evaluation:")
// sort rules by name for consistent output
rules := make([]string, 0, len(e.Results))
for name := range e.Results {
rules = append(rules, name)
}
sort.Strings(rules)
ruleResults := []string{}
for _, rule := range rules {
result := e.Results[rule]
ruleResult := e.formatRuleResult(rule, result)
ruleResults = append(ruleResults, ruleResult)
}
status := e.renderStatusSummary()
return title + "\n" + strings.Join(ruleResults, "\n") + "\n\n" + status
}
func (e Evaluator) formatRuleResult(ruleName string, result RuleResult) string {
var style lipgloss.Style
textStyle := lipgloss.NewStyle()
switch result.status {
case RulePassed:
style = e.format.Success
case RuleFailed:
style = e.format.Failure
case RuleWarning, RuleMisconfigured:
style = e.format.Warning
case RuleDisabled:
style = e.format.Disabled
textStyle = e.format.Disabled
default:
style = lipgloss.NewStyle()
}
statusStr := style.Render(result.status.String(e.format))
if result.message != "" {
return fmt.Sprintf(" %s %s", statusStr, textStyle.Render(ruleName+" ("+result.message+")"))
}
return fmt.Sprintf(" %s %s", statusStr, textStyle.Render(ruleName))
}
func (e Evaluator) renderStatusSummary() string {
if e.Misconfigured {
return e.format.Failure.Render("CI Misconfigured")
}
status := "PASS"
if e.Tally.Fail > 0 {
status = "FAIL"
}
parts := []string{}
type tallyItem struct {
name string
value int
}
items := []tallyItem{
//{"total", e.Tally.Total},
{"pass", e.Tally.Pass},
{"fail", e.Tally.Fail},
{"warn", e.Tally.Warn},
{"skip", e.Tally.Skip},
}
for _, item := range items {
if item.value > 0 {
parts = append(parts, fmt.Sprintf("%s:%d", item.name, item.value))
}
}
auxSummary := e.format.Aux.Render(" [" + strings.Join(parts, " ") + "]")
var style lipgloss.Style
switch {
case e.Pass && e.Tally.Warn == 0:
style = e.format.Success
case e.Pass && e.Tally.Warn > 0:
style = e.format.Warning
default:
style = e.format.Failure
}
return style.Render(status) + auxSummary
}
func formatKeyValue(f format, key, value string) string {
formattedKey := f.Label.Render(key + ":")
return fmt.Sprintf(" %s %s", formattedKey, value)
}

View file

@ -0,0 +1,198 @@
package ci
import (
"context"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/wagoodman/dive/dive/image/docker"
)
var repoRootCache atomic.String
func Test_Evaluator(t *testing.T) {
result := docker.TestAnalysisFromArchive(t, repoPath(t, ".data/test-docker-image.tar"))
validTests := []struct {
name string
efficiency string
wastedBytes string
wastedPercent string
expectedPass bool
expectedResult map[string]RuleStatus
}{
{
name: "allFail",
efficiency: "0.99",
wastedBytes: "1B",
wastedPercent: "0.01",
expectedPass: false,
expectedResult: map[string]RuleStatus{
"lowestEfficiency": RuleFailed,
"highestWastedBytes": RuleFailed,
"highestUserWastedPercent": RuleFailed,
},
},
{
name: "allPass",
efficiency: "0.9",
wastedBytes: "50kB",
wastedPercent: "0.5",
expectedPass: true,
expectedResult: map[string]RuleStatus{
"lowestEfficiency": RulePassed,
"highestWastedBytes": RulePassed,
"highestUserWastedPercent": RulePassed,
},
},
{
name: "allDisabled",
efficiency: "disabled",
wastedBytes: "disabled",
wastedPercent: "disabled",
expectedPass: true,
expectedResult: map[string]RuleStatus{
"lowestEfficiency": RuleDisabled,
"highestWastedBytes": RuleDisabled,
"highestUserWastedPercent": RuleDisabled,
},
},
{
name: "mixedResults",
efficiency: "0.9",
wastedBytes: "1B",
wastedPercent: "0.5",
expectedPass: false,
expectedResult: map[string]RuleStatus{
"lowestEfficiency": RulePassed,
"highestWastedBytes": RuleFailed,
"highestUserWastedPercent": RulePassed,
},
},
}
for _, test := range validTests {
t.Run(test.name, func(t *testing.T) {
// Create rules - these should not error
rules, err := Rules(test.efficiency, test.wastedBytes, test.wastedPercent)
require.NoError(t, err)
evaluator := NewEvaluator(rules)
eval := evaluator.Evaluate(context.TODO(), result)
if test.expectedPass != eval.Pass {
t.Errorf("expected pass=%v, got %v", test.expectedPass, eval.Pass)
}
if len(test.expectedResult) != len(evaluator.Results) {
t.Errorf("expected %v results, got %v", len(test.expectedResult), len(evaluator.Results))
}
for rule, actualResult := range evaluator.Results {
expectedStatus := test.expectedResult[rule]
if expectedStatus != actualResult.status {
t.Errorf("%v: expected %v rule status, got %v: %v",
rule, expectedStatus, actualResult.status, actualResult)
}
}
})
}
}
func Test_Evaluator_Misconfigurations(t *testing.T) {
invalidTests := []struct {
name string
efficiency string
wastedBytes string
wastedPercent string
expectError bool
}{
{
name: "invalid_efficiency_too_high",
efficiency: "1.1", // fail!
wastedBytes: "50kB",
wastedPercent: "0.5",
expectError: true,
},
{
name: "invalid_efficiency_too_low",
efficiency: "-0.1", // fail!
wastedBytes: "50kB",
wastedPercent: "0.5",
expectError: true,
},
{
name: "invalid_efficiency_format",
efficiency: "not_a_number", // fail!
wastedBytes: "50kB",
wastedPercent: "0.5",
expectError: true,
},
{
name: "invalid_wasted_bytes_format",
efficiency: "0.9",
wastedBytes: "not_a_size", // fail!
wastedPercent: "0.5",
expectError: true,
},
{
name: "invalid_wasted_percent_high",
efficiency: "0.9",
wastedBytes: "50kB",
wastedPercent: "1.1", // fail!
expectError: true,
},
{
name: "invalid_wasted_percent_low",
efficiency: "0.9",
wastedBytes: "50kB",
wastedPercent: "-0.1", // fail!
expectError: true,
},
{
name: "invalid_wasted_percent_format",
efficiency: "0.9",
wastedBytes: "50kB",
wastedPercent: "not_a_number", // fail!
expectError: true,
},
}
for _, test := range invalidTests {
t.Run(test.name, func(t *testing.T) {
_, err := Rules(test.efficiency, test.wastedBytes, test.wastedPercent)
if test.expectError {
require.Error(t, err, "Expected an error for invalid configuration")
} else {
require.NoError(t, err, "Expected no error for valid configuration")
}
})
}
}
func repoPath(t testing.TB, path string) string {
t.Helper()
root := repoRoot(t)
return filepath.Join(root, path)
}
func repoRoot(t testing.TB) string {
val := repoRootCache.Load()
if val != "" {
return val
}
t.Helper()
// use git to find the root of the repo
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
t.Fatalf("failed to get repo root: %v", err)
}
val = strings.TrimSpace(string(out))
repoRootCache.Store(val)
return val
}

View file

@ -0,0 +1,47 @@
package ci
import (
"github.com/wagoodman/dive/dive/image"
)
const (
RuleUnknown = iota
RulePassed
RuleFailed
RuleWarning
RuleDisabled
RuleMisconfigured
RuleConfigured
)
type Rule interface {
Key() string
Configuration() string
Evaluate(result *image.Analysis) (RuleStatus, string)
}
type RuleStatus int
type RuleResult struct {
status RuleStatus
message string
}
func (status RuleStatus) String(f format) string {
switch status {
case RulePassed:
return f.Success.Render("PASS")
case RuleFailed:
return f.Failure.Render("FAIL")
case RuleWarning:
return f.Warning.Render("WARN")
case RuleDisabled:
return f.Disabled.Render("SKIP")
case RuleMisconfigured:
return f.Warning.Render("MISCONFIGURED")
case RuleConfigured:
return "CONFIGURED "
default:
return f.Warning.Render("Unknown")
}
}

View file

@ -0,0 +1,196 @@
package ci
import (
"errors"
"fmt"
"github.com/dustin/go-humanize"
"github.com/wagoodman/dive/dive/image"
"strconv"
"strings"
)
const (
ciKeyLowestEfficiencyThreshold = "lowestEfficiency"
ciKeyHighestWastedBytes = "highestWastedBytes"
ciKeyHighestUserWastedPercent = "highestUserWastedPercent"
)
func Rules(lowerEfficiency, highestWastedBytes, highestUserWastedPercent string) ([]Rule, error) {
var rules []Rule
var errs []error
lowestEfficiencyRule, err := NewLowestEfficiencyRule(lowerEfficiency)
if err != nil {
errs = append(errs, err)
}
rules = append(rules, lowestEfficiencyRule)
highestWastedBytesRule, err := NewHighestWastedBytesRule(highestWastedBytes)
if err != nil {
errs = append(errs, err)
}
rules = append(rules, highestWastedBytesRule)
highestUserWastedPercentRule, err := NewHighestUserWastedPercentRule(highestUserWastedPercent)
if err != nil {
errs = append(errs, err)
}
rules = append(rules, highestUserWastedPercentRule)
return rules, errors.Join(errs...)
}
func DisabledRule(key string) Rule {
return &BaseRule{
key: key,
configValue: "disabled",
evaluator: func(_ *image.Analysis) (RuleStatus, string) {
return RuleDisabled, "rule disabled"
},
}
}
type BaseRule struct {
key string
configValue string
status RuleStatus
evaluator func(*image.Analysis) (RuleStatus, string)
}
func (rule *BaseRule) Key() string {
return rule.key
}
func (rule *BaseRule) Configuration() string {
return rule.configValue
}
func (rule *BaseRule) Evaluate(result *image.Analysis) (RuleStatus, string) {
if rule.status != RuleUnknown {
return rule.status, ""
}
return rule.evaluator(result)
}
// LowestEfficiencyRule checks if image efficiency is above threshold
type LowestEfficiencyRule struct {
BaseRule
threshold float64
}
// HighestWastedBytesRule checks if wasted bytes are below threshold
type HighestWastedBytesRule struct {
BaseRule
threshold uint64
}
// HighestUserWastedPercentRule checks if percentage of wasted bytes is below threshold
type HighestUserWastedPercentRule struct {
BaseRule
threshold float64
}
func NewLowestEfficiencyRule(configValue string) (Rule, error) {
if isRuleDisabled(configValue) {
return DisabledRule(ciKeyLowestEfficiencyThreshold), nil
}
threshold, err := strconv.ParseFloat(configValue, 64)
if err != nil {
return nil, fmt.Errorf("invalid %s config value, given %q: %v",
ciKeyLowestEfficiencyThreshold, configValue, err)
}
if threshold < 0 || threshold > 1 {
return nil, fmt.Errorf("%s config value is outside allowed range (0-1), given '%f'",
ciKeyLowestEfficiencyThreshold, threshold)
}
return &LowestEfficiencyRule{
BaseRule: BaseRule{
key: ciKeyLowestEfficiencyThreshold,
configValue: configValue,
},
threshold: threshold,
}, nil
}
func (r *LowestEfficiencyRule) Evaluate(analysis *image.Analysis) (RuleStatus, string) {
if r.threshold > analysis.Efficiency {
return RuleFailed, fmt.Sprintf(
"image efficiency is too low (efficiency=%2.2f < threshold=%v)",
analysis.Efficiency, r.threshold)
}
return RulePassed, ""
}
// NewHighestWastedBytesRule creates a new rule to check wasted bytes
func NewHighestWastedBytesRule(configValue string) (Rule, error) {
if isRuleDisabled(configValue) {
return DisabledRule(ciKeyHighestWastedBytes), nil
}
threshold, err := humanize.ParseBytes(configValue)
if err != nil {
return nil, fmt.Errorf("invalid highestWastedBytes config value, given %q: %v",
configValue, err)
}
return &HighestWastedBytesRule{
BaseRule: BaseRule{
key: ciKeyHighestWastedBytes,
configValue: configValue,
},
threshold: threshold,
}, nil
}
func (r *HighestWastedBytesRule) Evaluate(analysis *image.Analysis) (RuleStatus, string) {
if analysis.WastedBytes > r.threshold {
return RuleFailed, fmt.Sprintf(
"too many bytes wasted (wasted-bytes=%d > threshold=%v)",
analysis.WastedBytes, r.threshold)
}
return RulePassed, ""
}
// NewHighestUserWastedPercentRule creates a new rule to check percentage of wasted bytes
func NewHighestUserWastedPercentRule(configValue string) (Rule, error) {
if isRuleDisabled(configValue) {
return DisabledRule(ciKeyHighestUserWastedPercent), nil
}
threshold, err := strconv.ParseFloat(configValue, 64)
if err != nil {
return nil, fmt.Errorf("invalid highestUserWastedPercent config value, given %q: %v",
configValue, err)
}
if threshold < 0 || threshold > 1 {
return nil, fmt.Errorf("highestUserWastedPercent config value is outside allowed range (0-1), given '%f'",
threshold)
}
return &HighestUserWastedPercentRule{
BaseRule: BaseRule{
key: ciKeyHighestUserWastedPercent,
configValue: configValue,
},
threshold: threshold,
}, nil
}
func (r *HighestUserWastedPercentRule) Evaluate(analysis *image.Analysis) (RuleStatus, string) {
if analysis.WastedUserPercent > r.threshold {
return RuleFailed, fmt.Sprintf(
"too many bytes wasted, relative to the user bytes added (%%-user-wasted-bytes=%2.2f > threshold=%v)",
analysis.WastedUserPercent, r.threshold)
}
return RulePassed, ""
}
func isRuleDisabled(value string) bool {
value = strings.TrimSpace(strings.ToLower(value))
return value == "" || value == "disabled" || value == "off" || value == "false"
}

View file

@ -0,0 +1,86 @@
package export
import (
"encoding/json"
"github.com/wagoodman/dive/dive/filetree"
diveImage "github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/internal/log"
)
type Export struct {
Layer []Layer `json:"layer"`
Image Image `json:"image"`
}
type Layer struct {
Index int `json:"index"`
ID string `json:"id"`
DigestID string `json:"digestId"`
SizeBytes uint64 `json:"sizeBytes"`
Command string `json:"command"`
FileList []filetree.FileInfo `json:"fileList"`
}
type Image struct {
SizeBytes uint64 `json:"sizeBytes"`
InefficientBytes uint64 `json:"inefficientBytes"`
EfficiencyScore float64 `json:"efficiencyScore"`
InefficientFiles []FileReference `json:"fileReference"`
}
type FileReference struct {
References int `json:"count"`
SizeBytes uint64 `json:"sizeBytes"`
Path string `json:"file"`
}
// NewExport exports the analysis to a JSON
func NewExport(analysis *diveImage.Analysis) *Export {
data := Export{
Layer: make([]Layer, len(analysis.Layers)),
Image: Image{
InefficientFiles: make([]FileReference, len(analysis.Inefficiencies)),
SizeBytes: analysis.SizeBytes,
EfficiencyScore: analysis.Efficiency,
InefficientBytes: analysis.WastedBytes,
},
}
// export layers in order
for idx, curLayer := range analysis.Layers {
layerFileList := make([]filetree.FileInfo, 0)
visitor := func(node *filetree.FileNode) error {
layerFileList = append(layerFileList, node.Data.FileInfo)
return nil
}
err := curLayer.Tree.VisitDepthChildFirst(visitor, nil)
if err != nil {
log.WithFields("layer", curLayer.Id, "error", err).Debug("unable to propagate layer tree")
}
data.Layer[idx] = Layer{
Index: curLayer.Index,
ID: curLayer.Id,
DigestID: curLayer.Digest,
SizeBytes: curLayer.Size,
Command: curLayer.Command,
FileList: layerFileList,
}
}
// add file references
for idx := 0; idx < len(analysis.Inefficiencies); idx++ {
fileData := analysis.Inefficiencies[len(analysis.Inefficiencies)-1-idx]
data.Image.InefficientFiles[idx] = FileReference{
References: len(fileData.Nodes),
SizeBytes: uint64(fileData.CumulativeSize),
Path: fileData.Path,
}
}
return &data
}
func (exp *Export) Marshal() ([]byte, error) {
return json.MarshalIndent(&exp, "", " ")
}

View file

@ -0,0 +1,19 @@
package export
import (
"testing"
"github.com/wagoodman/dive/dive/image/docker"
)
func Test_Export(t *testing.T) {
result := docker.TestAnalysisFromArchive(t, repoPath(t, ".data/test-docker-image.tar"))
export := NewExport(result)
payload, err := export.Marshal()
if err != nil {
t.Errorf("Test_Export: unable to export analysis: %v", err)
}
snaps.MatchJSON(t, payload)
}

View file

@ -0,0 +1,68 @@
package export
import (
"flag"
"github.com/charmbracelet/lipgloss"
snapsPkg "github.com/gkampitakis/go-snaps/snaps"
"github.com/muesli/termenv"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var (
updateSnapshot = flag.Bool("update", false, "update any test snapshots")
snaps *snapsPkg.Config
repoRootCache atomic.String
)
func TestMain(m *testing.M) {
// flags are not parsed until after test.Main is called...
flag.Parse()
os.Unsetenv("DIVE_CONFIG")
// disable colors
lipgloss.SetColorProfile(termenv.Ascii)
snaps = snapsPkg.WithConfig(
snapsPkg.Update(*updateSnapshot),
snapsPkg.Dir("testdata/snapshots"),
)
v := m.Run()
snapsPkg.Clean(m)
os.Exit(v)
}
func TestUpdateSnapshotDisabled(t *testing.T) {
require.False(t, *updateSnapshot, "update snapshot flag should be disabled")
}
func repoPath(t testing.TB, path string) string {
t.Helper()
root := repoRoot(t)
return filepath.Join(root, path)
}
func repoRoot(t testing.TB) string {
val := repoRootCache.Load()
if val != "" {
return val
}
t.Helper()
// use git to find the root of the repo
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
t.Fatalf("failed to get repo root: %v", err)
}
val = strings.TrimSpace(string(out))
repoRootCache.Store(val)
return val
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,99 @@
package command
import (
"context"
"errors"
"fmt"
"github.com/anchore/clio"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/wagoodman/dive/cmd/dive/cli/internal/command/adapter"
"github.com/wagoodman/dive/cmd/dive/cli/internal/options"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui"
"github.com/wagoodman/dive/dive"
"github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/internal/bus"
"os"
)
type rootOptions struct {
options.Application `yaml:",inline" mapstructure:",squash"`
// reserved for future use of root-only flags
}
func Root(app clio.Application) *cobra.Command {
opts := &rootOptions{
Application: options.DefaultApplication(),
}
return app.SetupRootCommand(&cobra.Command{
Use: "dive [IMAGE]",
Short: "Docker Image Visualizer & Explorer",
Long: `This tool provides a way to discover and explore the contents of a docker image. Additionally the tool estimates
the amount of wasted space and identifies the offending files from the image.`,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("exactly one argument is required")
}
opts.Analysis.Image = args[0]
return nil
},
RunE: func(cmd *cobra.Command, _ []string) error {
if err := setUI(app, opts.Application); err != nil {
return fmt.Errorf("failed to set UI: %w", err)
}
resolver, err := dive.GetImageResolver(opts.Analysis.Source)
if err != nil {
return fmt.Errorf("cannot determine image provider to fetch from: %w", err)
}
ctx := cmd.Context()
img, err := adapter.ImageResolver(resolver).Fetch(ctx, opts.Analysis.Image)
if err != nil {
return fmt.Errorf("cannot load image: %w", err)
}
return run(ctx, opts.Application, img, resolver)
},
}, opts)
}
func setUI(app clio.Application, opts options.Application) error {
type Stater interface {
State() *clio.State
}
state := app.(Stater).State()
ux := ui.NewV1UI(opts.V1Preferences(), os.Stdout, state.Config.Log.Quiet, state.Config.Log.Verbosity)
return state.UI.Replace(ux)
}
func run(ctx context.Context, opts options.Application, img *image.Image, content image.ContentReader) error {
analysis, err := adapter.NewAnalyzer().Analyze(ctx, img)
if err != nil {
return fmt.Errorf("cannot analyze image: %w", err)
}
if opts.Export.JsonPath != "" {
if err := adapter.NewExporter(afero.NewOsFs()).ExportTo(ctx, analysis, opts.Export.JsonPath); err != nil {
return fmt.Errorf("cannot export analysis: %w", err)
}
return nil
}
if opts.CI.Enabled {
eval := adapter.NewEvaluator(opts.CI.Rules.List).Evaluate(ctx, analysis)
if !eval.Pass {
return errors.New("evaluation failed")
}
return nil
}
bus.ExploreAnalysis(*analysis, content)
return nil
}

View file

@ -0,0 +1,75 @@
package options
import (
"fmt"
"github.com/anchore/clio"
"github.com/scylladb/go-set/strset"
"github.com/wagoodman/dive/dive"
"github.com/wagoodman/dive/internal/log"
"strings"
)
const defaultContainerEngine = "docker"
var _ interface {
clio.PostLoader
clio.FieldDescriber
} = (*Analysis)(nil)
// Analysis provides configuration for the image analysis behavior
type Analysis struct {
Image string `yaml:"image" mapstructure:"-"`
ContainerEngine string `yaml:"container-engine" mapstructure:"container-engine"`
Source dive.ImageSource `yaml:"-" mapstructure:"-"`
IgnoreErrors bool `yaml:"ignore-errors" mapstructure:"ignore-errors"`
AvailableContainerEngines []string `yaml:"-" mapstructure:"-"`
}
func DefaultAnalysis() Analysis {
return Analysis{
ContainerEngine: defaultContainerEngine,
IgnoreErrors: false,
AvailableContainerEngines: dive.ImageSources,
}
}
func (c *Analysis) DescribeFields(descriptions clio.FieldDescriptionSet) {
descriptions.Add(&c.ContainerEngine, "container engine to use for image analysis (supported options: 'docker' and 'podman')")
descriptions.Add(&c.IgnoreErrors, "continue with analysis even if there are errors parsing the image archive")
}
func (c *Analysis) AddFlags(flags clio.FlagSet) {
flags.StringVarP(&c.ContainerEngine, "source", "",
fmt.Sprintf("The container engine to fetch the image from. Allowed values: %s", strings.Join(c.AvailableContainerEngines, ", ")))
flags.BoolVarP(&c.IgnoreErrors, "ignore-errors", "i", "ignore image parsing errors and run the analysis anyway")
}
func (c *Analysis) PostLoad() error {
validEngines := strset.New(c.AvailableContainerEngines...)
if !validEngines.Has(c.ContainerEngine) {
log.Warnf("invalid container engine: %s (valid options: %s), using default %q", c.ContainerEngine, strings.Join(c.AvailableContainerEngines, ", "), defaultContainerEngine)
c.ContainerEngine = "docker"
}
if c.Image != "" {
sourceType, imageStr := dive.DeriveImageSource(c.Image)
if sourceType == dive.SourceUnknown {
sourceType = dive.ParseImageSource(c.ContainerEngine)
if sourceType == dive.SourceUnknown {
return fmt.Errorf("unable to determine image source from %q: %v\n", c.Image, c.ContainerEngine)
}
// use exactly what the user provided
imageStr = c.Image
}
c.Image = imageStr
c.Source = sourceType
} else {
c.Source = dive.ParseImageSource(c.ContainerEngine)
}
return nil
}

View file

@ -0,0 +1,32 @@
package options
import (
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
)
type Application struct {
Analysis Analysis `yaml:",inline" mapstructure:",squash"`
CI CI `yaml:",inline" mapstructure:",squash"`
Export Export `yaml:",inline" mapstructure:",squash"`
UI UI `yaml:",inline" mapstructure:",squash"`
}
func DefaultApplication() Application {
return Application{
Analysis: DefaultAnalysis(),
CI: DefaultCI(),
Export: DefaultExport(),
UI: DefaultUI(),
}
}
func (c Application) V1Preferences() v1.Preferences {
return v1.Preferences{
KeyBindings: c.UI.Keybinding.Config,
ShowFiletreeAttributes: c.UI.Filetree.ShowAttributes,
ShowAggregatedLayerChanges: c.UI.Layer.ShowAggregatedChanges,
CollapseFiletreeDirectory: c.UI.Filetree.CollapseDir,
FiletreePaneWidth: c.UI.Filetree.PaneWidth,
FiletreeDiffHide: nil,
}
}

View file

@ -0,0 +1,105 @@
package options
import (
"fmt"
"github.com/anchore/clio"
"gopkg.in/yaml.v3"
"os"
)
var _ interface {
clio.PostLoader
clio.FieldDescriber
clio.FlagAdder
} = (*CI)(nil)
const defaultCIConfigPath = ".dive-ci"
type CI struct {
Enabled bool `yaml:"ci" mapstructure:"ci"`
ConfigPath string `yaml:"ci-config" mapstructure:"ci-config"`
Rules CIRules `yaml:"rules" mapstructure:"rules"`
}
func DefaultCI() CI {
return CI{
Enabled: false,
ConfigPath: defaultCIConfigPath,
Rules: DefaultCIRules(),
}
}
func (c *CI) DescribeFields(descriptions clio.FieldDescriptionSet) {
descriptions.Add(&c.Enabled, "enable CI mode")
descriptions.Add(&c.ConfigPath, "path to the CI config file")
}
func (c *CI) AddFlags(flags clio.FlagSet) {
flags.BoolVarP(&c.Enabled, "ci", "", "skip the interactive TUI and validate against CI rules (same as env var CI=true)")
flags.StringVarP(&c.ConfigPath, "ci-config", "", "if CI=true in the environment, use the given yaml to drive validation rules.")
}
func (c *CI) PostLoad() error {
enabledFromEnv := truthy(os.Getenv("CI"))
if !c.Enabled && enabledFromEnv {
c.Enabled = true
}
if c.ConfigPath != "" {
if fileExists(c.ConfigPath) {
// if a config file is provided, load it and override any values provided in the application config.
// If we're hitting this case we should pretend that only the config file was provided and applied
// on top of the default config values.
yamlFile, err := os.ReadFile(c.ConfigPath)
if err != nil {
return fmt.Errorf("failed to read CI config file %s: %w", c.ConfigPath, err)
}
def := DefaultCIRules()
r := legacyRuleFile{
LowestEfficiencyThresholdString: def.LowestEfficiencyThresholdString,
HighestWastedBytesString: def.HighestWastedBytesString,
HighestUserWastedPercentString: def.HighestUserWastedPercentString,
}
wrapper := struct {
Rules *legacyRuleFile `yaml:"rules"`
}{
Rules: &r,
}
if err := yaml.Unmarshal(yamlFile, &wrapper); err != nil {
return fmt.Errorf("failed to unmarshal CI config file %s: %w", c.ConfigPath, err)
}
// TODO: should this be a deprecated use warning in the future?
c.Rules = CIRules{
LowestEfficiencyThresholdString: r.LowestEfficiencyThresholdString,
HighestWastedBytesString: r.HighestWastedBytesString,
HighestUserWastedPercentString: r.HighestUserWastedPercentString,
}
}
}
return nil
}
type legacyRuleFile struct {
LowestEfficiencyThresholdString string `yaml:"lowestEfficiency"`
HighestWastedBytesString string `yaml:"highestWastedBytes"`
HighestUserWastedPercentString string `yaml:"highestUserWastedPercent"`
}
func fileExists(path string) bool {
if _, err := os.Stat(path); os.IsNotExist(err) {
return false
}
return true
}
func truthy(value string) bool {
switch value {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
default:
return false
}
}

View file

@ -0,0 +1,73 @@
package options
import (
"github.com/anchore/clio"
"github.com/wagoodman/dive/cmd/dive/cli/internal/command/ci"
"github.com/wagoodman/dive/internal/log"
)
type CIRules struct {
LowestEfficiencyThresholdString string `yaml:"lowest-efficiency" mapstructure:"lowest-efficiency"`
LegacyLowestEfficiencyThresholdString string `yaml:"-" mapstructure:"lowestEfficiency"`
HighestWastedBytesString string `yaml:"highest-wasted-bytes" mapstructure:"highest-wasted-bytes"`
LegacyHighestWastedBytesString string `yaml:"-" mapstructure:"highestWastedBytes"`
HighestUserWastedPercentString string `yaml:"highest-user-wasted-percent" mapstructure:"highest-user-wasted-percent"`
LegacyHighestUserWastedPercentString string `yaml:"-" mapstructure:"highestUserWastedPercent"`
List []ci.Rule `yaml:"-" mapstructure:"-"`
}
func DefaultCIRules() CIRules {
return CIRules{
LowestEfficiencyThresholdString: "0.9",
HighestWastedBytesString: "disabled",
HighestUserWastedPercentString: "0.1",
}
}
func (c *CIRules) DescribeFields(descriptions clio.FieldDescriptionSet) {
descriptions.Add(&c.LowestEfficiencyThresholdString, "lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail.")
descriptions.Add(&c.HighestWastedBytesString, "highest allowable bytes wasted, otherwise CI validation will fail.")
descriptions.Add(&c.HighestUserWastedPercentString, "highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail.")
}
func (c *CIRules) AddFlags(flags clio.FlagSet) {
flags.StringVarP(&c.LowestEfficiencyThresholdString, "lowestEfficiency", "", "(only valid with --ci given) lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail.")
flags.StringVarP(&c.HighestWastedBytesString, "highestWastedBytes", "", "(only valid with --ci given) highest allowable bytes wasted, otherwise CI validation will fail.")
flags.StringVarP(&c.HighestUserWastedPercentString, "highestUserWastedPercent", "", "(only valid with --ci given) highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail.")
}
func (c CIRules) hasLegacyOptionsInUse() bool {
return c.LegacyLowestEfficiencyThresholdString != "" || c.LegacyHighestWastedBytesString != "" || c.LegacyHighestUserWastedPercentString != ""
}
func (c *CIRules) PostLoad() error {
// protect against repeated calls
c.List = nil
if c.hasLegacyOptionsInUse() {
log.Warnf("please specify ci rules in snake-case (the legacy camelCase format is deprecated)")
}
if c.LegacyLowestEfficiencyThresholdString != "" {
c.LowestEfficiencyThresholdString = c.LegacyLowestEfficiencyThresholdString
}
if c.LegacyHighestWastedBytesString != "" {
c.HighestWastedBytesString = c.LegacyHighestWastedBytesString
}
if c.LegacyHighestUserWastedPercentString != "" {
c.HighestUserWastedPercentString = c.LegacyHighestUserWastedPercentString
}
rules, err := ci.Rules(c.LowestEfficiencyThresholdString, c.HighestWastedBytesString, c.HighestUserWastedPercentString)
if err != nil {
return err
}
c.List = append(c.List, rules...)
return nil
}

View file

@ -0,0 +1,40 @@
package options
import (
"fmt"
"os"
"path"
"github.com/anchore/clio"
)
var _ interface {
clio.FlagAdder
clio.PostLoader
} = (*Export)(nil)
// Export provides configuration for data export functionality
type Export struct {
// Path to export analysis results as JSON (empty string = disabled)
JsonPath string `yaml:"json-path" json:"json-path" mapstructure:"json-path"`
}
func DefaultExport() Export {
return Export{}
}
func (o *Export) AddFlags(flags clio.FlagSet) {
flags.StringVarP(&o.JsonPath, "json", "j", "Skip the interactive TUI and write the layer analysis statistics to a given file.")
}
func (o *Export) PostLoad() error {
if o.JsonPath != "" {
dir := path.Dir(o.JsonPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
return fmt.Errorf("directory for JSON export does not exist: %s", dir)
}
}
return nil
}

View file

@ -0,0 +1,18 @@
package options
// UI combines all UI configuration elements
type UI struct {
Keybinding UIKeybindings `yaml:"keybinding" mapstructure:"keybinding"`
Diff UIDiff `yaml:"diff" mapstructure:"diff"`
Filetree UIFiletree `yaml:"filetree" mapstructure:"filetree"`
Layer UILayers `yaml:"layer" mapstructure:"layer"`
}
func DefaultUI() UI {
return UI{
Keybinding: DefaultUIKeybinding(),
Diff: DefaultUIDiff(),
Filetree: DefaultUIFiletree(),
Layer: DefaultUILayers(),
}
}

View file

@ -0,0 +1,39 @@
package options
import (
"github.com/anchore/clio"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"github.com/wagoodman/dive/internal/log"
)
var _ interface {
clio.PostLoader
clio.FieldDescriber
} = (*UIDiff)(nil)
// UIDiff provides configuration for how differences are displayed
type UIDiff struct {
Hide []string `yaml:"hide" mapstructure:"hide"`
}
func DefaultUIDiff() UIDiff {
prefs := v1.DefaultPreferences()
return UIDiff{
Hide: prefs.FiletreeDiffHide,
}
}
func (c *UIDiff) DescribeFields(descriptions clio.FieldDescriptionSet) {
descriptions.Add(&c.Hide, "types of file differences to hide (added, removed, modified, unmodified)")
}
func (c *UIDiff) PostLoad() error {
validHideValues := map[string]bool{"added": true, "removed": true, "modified": true, "unmodified": true}
for _, value := range c.Hide {
if _, ok := validHideValues[value]; !ok {
log.Warnf("invalid diff hide value: %s (valid values: added, removed, modified, unmodified)", value)
}
}
return nil
}

View file

@ -0,0 +1,43 @@
package options
import (
"github.com/anchore/clio"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"github.com/wagoodman/dive/internal/log"
)
var _ interface {
clio.PostLoader
clio.FieldDescriber
} = (*UIFiletree)(nil)
// UIFiletree provides configuration for the file tree display
type UIFiletree struct {
CollapseDir bool `yaml:"collapse-dir" mapstructure:"collapse-dir"`
PaneWidth float64 `yaml:"pane-width" mapstructure:"pane-width"`
ShowAttributes bool `yaml:"show-attributes" mapstructure:"show-attributes"`
}
func DefaultUIFiletree() UIFiletree {
prefs := v1.DefaultPreferences()
return UIFiletree{
CollapseDir: prefs.CollapseFiletreeDirectory,
PaneWidth: prefs.FiletreePaneWidth,
ShowAttributes: prefs.ShowFiletreeAttributes,
}
}
func (c *UIFiletree) DescribeFields(descriptions clio.FieldDescriptionSet) {
descriptions.Add(&c.CollapseDir, "collapse directories by default in the filetree")
descriptions.Add(&c.PaneWidth, "percentage of screen width for the filetree pane (must be >0 and <1)")
descriptions.Add(&c.ShowAttributes, "show file attributes in the filetree view")
}
func (c *UIFiletree) PostLoad() error {
// Validate pane width is between 0 and 1
if c.PaneWidth <= 0 || c.PaneWidth >= 1 {
log.Warnf("filetree pane-width must be >0 and <1, got %v, resetting to default 0.5", c.PaneWidth)
c.PaneWidth = 0.5
}
return nil
}

View file

@ -0,0 +1,178 @@
package options
import (
"github.com/anchore/clio"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key"
"reflect"
)
var _ interface {
clio.FieldDescriber
} = (*UIKeybindings)(nil)
// UIKeybindings provides configuration for all keyboard shortcuts
type UIKeybindings struct {
Global GlobalBindings `yaml:",inline" mapstructure:",squash"`
Navigation NavigationBindings `yaml:",inline" mapstructure:",squash"`
Layer LayerBindings `yaml:",inline" mapstructure:",squash"`
Filetree FiletreeBindings `yaml:",inline" mapstructure:",squash"`
Config key.Bindings `yaml:"-" mapstructure:"-"`
}
type GlobalBindings struct {
Quit string `yaml:"quit" mapstructure:"quit"`
ToggleView string `yaml:"toggle-view" mapstructure:"toggle-view"`
FilterFiles string `yaml:"filter-files" mapstructure:"filter-files"`
CloseFilterFiles string `yaml:"close-filter-files" mapstructure:"close-filter-files"`
}
type NavigationBindings struct {
Up string `yaml:"up" mapstructure:"up"`
Down string `yaml:"down" mapstructure:"down"`
Left string `yaml:"left" mapstructure:"left"`
Right string `yaml:"right" mapstructure:"right"`
PageUp string `yaml:"page-up" mapstructure:"page-up"`
PageDown string `yaml:"page-down" mapstructure:"page-down"`
}
type LayerBindings struct {
CompareAll string `yaml:"compare-all" mapstructure:"compare-all"`
CompareLayer string `yaml:"compare-layer" mapstructure:"compare-layer"`
}
type FiletreeBindings struct {
ToggleCollapseDir string `yaml:"toggle-collapse-dir" mapstructure:"toggle-collapse-dir"`
ToggleCollapseAllDir string `yaml:"toggle-collapse-all-dir" mapstructure:"toggle-collapse-all-dir"`
ToggleAddedFiles string `yaml:"toggle-added-files" mapstructure:"toggle-added-files"`
ToggleRemovedFiles string `yaml:"toggle-removed-files" mapstructure:"toggle-removed-files"`
ToggleModifiedFiles string `yaml:"toggle-modified-files" mapstructure:"toggle-modified-files"`
ToggleUnmodifiedFiles string `yaml:"toggle-unmodified-files" mapstructure:"toggle-unmodified-files"`
ToggleTreeAttributes string `yaml:"toggle-filetree-attributes" mapstructure:"toggle-filetree-attributes"`
ToggleSortOrder string `yaml:"toggle-sort-order" mapstructure:"toggle-sort-order"`
ToggleWrapTree string `yaml:"toggle-wrap-tree" mapstructure:"toggle-wrap-tree"`
ExtractFile string `yaml:"extract-file" mapstructure:"extract-file"`
}
func DefaultUIKeybinding() UIKeybindings {
var result UIKeybindings
defaults := key.DefaultBindings()
// converts from key.Bindings to UIKeybindings
getUIBindingValues(reflect.ValueOf(defaults), reflect.ValueOf(&result).Elem())
return result
}
func getUIBindingValues(src, dst reflect.Value) {
switch src.Kind() {
case reflect.Struct:
for i := 0; i < src.NumField(); i++ {
srcField := src.Field(i)
srcType := src.Type().Field(i)
if !srcField.CanInterface() {
continue
}
dstField := dst.FieldByName(srcType.Name)
if !dstField.IsValid() || !dstField.CanSet() {
continue
}
if srcType.Type.Name() == "Config" {
inputField := srcField.FieldByName("Input")
if inputField.IsValid() && dstField.Kind() == reflect.String {
dstField.SetString(inputField.String())
}
continue
}
getUIBindingValues(srcField, dstField)
}
}
}
func (c *UIKeybindings) PostLoad() error {
cfg := key.Bindings{}
// convert UIKeybindings to key.Bindings
err := createKeyBindings(reflect.ValueOf(c).Elem(), reflect.ValueOf(&cfg).Elem())
if err != nil {
return err
}
c.Config = cfg
return nil
}
func createKeyBindings(src, dst reflect.Value) error {
switch dst.Kind() {
case reflect.Struct:
for i := 0; i < dst.NumField(); i++ {
dstField := dst.Field(i)
dstType := dst.Type().Field(i)
if !dstField.CanSet() {
continue
}
srcField := src.FieldByName(dstType.Name)
if !srcField.IsValid() {
continue
}
if dstType.Type.Name() == "Config" {
inputField := dstField.FieldByName("Input")
if inputField.IsValid() && inputField.CanSet() && srcField.Kind() == reflect.String {
inputField.SetString(srcField.String())
// call the Setup method if it exists
setupMethod := dstField.Addr().MethodByName("Setup")
if setupMethod.IsValid() {
result := setupMethod.Call([]reflect.Value{})
if !result[0].IsNil() {
return result[0].Interface().(error)
}
}
}
continue
}
err := createKeyBindings(srcField, dstField)
if err != nil {
return err
}
}
}
return nil
}
func (c *UIKeybindings) DescribeFields(descriptions clio.FieldDescriptionSet) {
// global keybindings
descriptions.Add(&c.Global.Quit, "quit the application (global)")
descriptions.Add(&c.Global.ToggleView, "toggle between different views (global)")
descriptions.Add(&c.Global.FilterFiles, "filter files by name (global)")
descriptions.Add(&c.Global.CloseFilterFiles, "close file filtering (global)")
// navigation keybindings
descriptions.Add(&c.Navigation.Up, "move cursor up (global)")
descriptions.Add(&c.Navigation.Down, "move cursor down (global)")
descriptions.Add(&c.Navigation.Left, "move cursor left (global)")
descriptions.Add(&c.Navigation.Right, "move cursor right (global)")
descriptions.Add(&c.Navigation.PageUp, "scroll page up (file view)")
descriptions.Add(&c.Navigation.PageDown, "scroll page down (file view)")
// layer view keybindings
descriptions.Add(&c.Layer.CompareAll, "compare all layers (layer view)")
descriptions.Add(&c.Layer.CompareLayer, "compare specific layer (layer view)")
// file view keybindings
descriptions.Add(&c.Filetree.ToggleCollapseDir, "toggle directory collapse (file view)")
descriptions.Add(&c.Filetree.ToggleCollapseAllDir, "toggle collapse all directories (file view)")
descriptions.Add(&c.Filetree.ToggleAddedFiles, "toggle visibility of added files (file view)")
descriptions.Add(&c.Filetree.ToggleRemovedFiles, "toggle visibility of removed files (file view)")
descriptions.Add(&c.Filetree.ToggleModifiedFiles, "toggle visibility of modified files (file view)")
descriptions.Add(&c.Filetree.ToggleUnmodifiedFiles, "toggle visibility of unmodified files (file view)")
descriptions.Add(&c.Filetree.ToggleTreeAttributes, "toggle display of file attributes (file view)")
descriptions.Add(&c.Filetree.ToggleSortOrder, "toggle sort order (file view)")
descriptions.Add(&c.Filetree.ExtractFile, "extract file contents (file view)")
}

View file

@ -0,0 +1,20 @@
package options
import "github.com/anchore/clio"
var _ clio.FieldDescriber = (*UILayers)(nil)
// UILayers provides configuration for layer display behavior
type UILayers struct {
ShowAggregatedChanges bool `yaml:"show-aggregated-changes" mapstructure:"show-aggregated-changes"`
}
func DefaultUILayers() UILayers {
return UILayers{
ShowAggregatedChanges: false,
}
}
func (c *UILayers) DescribeFields(descriptions clio.FieldDescriptionSet) {
descriptions.Add(&c.ShowAggregatedChanges, "show aggregated changes across all previous layers")
}

View file

@ -0,0 +1,30 @@
package ui
import (
"github.com/wagoodman/go-partybus"
"github.com/anchore/clio"
)
var _ clio.UI = (*NoUI)(nil)
type NoUI struct {
subscription partybus.Unsubscribable
}
func None() *NoUI {
return &NoUI{}
}
func (n *NoUI) Setup(subscription partybus.Unsubscribable) error {
n.subscription = subscription
return nil
}
func (n *NoUI) Handle(_ partybus.Event) error {
return nil
}
func (n NoUI) Teardown(_ bool) error {
return nil
}

View file

@ -0,0 +1,176 @@
package ui
import (
"context"
"fmt"
"github.com/anchore/clio"
"github.com/anchore/go-logger/adapter/discard"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
v1 "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/app"
"github.com/wagoodman/dive/internal/bus/event"
"github.com/wagoodman/dive/internal/bus/event/parser"
"github.com/wagoodman/dive/internal/log"
"github.com/wagoodman/go-partybus"
"io"
"os"
"strings"
)
var _ clio.UI = (*V1UI)(nil)
type V1UI struct {
cfg v1.Preferences
out io.Writer
err io.Writer
subscription partybus.Unsubscribable
quiet bool
verbosity int
format format
}
type format struct {
Title lipgloss.Style
Aux lipgloss.Style
Line lipgloss.Style
Notification lipgloss.Style
}
func NewV1UI(cfg v1.Preferences, out io.Writer, quiet bool, verbosity int) *V1UI {
return &V1UI{
cfg: cfg,
out: out,
err: os.Stderr,
quiet: quiet,
verbosity: verbosity,
format: format{
Title: lipgloss.NewStyle().Bold(true).Width(30),
Aux: lipgloss.NewStyle().Faint(true),
Notification: lipgloss.NewStyle().Foreground(lipgloss.Color("#A77BCA")),
},
}
}
func (n *V1UI) Setup(subscription partybus.Unsubscribable) error {
if n.verbosity == 0 || n.quiet {
// we still use the UI, but we want to suppress responding to events that would print out what is already
// being logged.
log.Set(discard.New())
}
// remove CI var from consideration when determining if we should use the UI
lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(n.out, termenv.WithEnvironment(environWithoutCI{})))
n.subscription = subscription
return nil
}
var _ termenv.Environ = (*environWithoutCI)(nil)
type environWithoutCI struct {
}
func (e environWithoutCI) Environ() []string {
var out []string
for _, s := range os.Environ() {
if strings.HasPrefix(s, "CI=") {
continue
}
out = append(out, s)
}
return out
}
func (e environWithoutCI) Getenv(s string) string {
if s == "CI" {
return ""
}
return os.Getenv(s)
}
func (n *V1UI) Handle(e partybus.Event) error {
switch e.Type {
case event.TaskStarted:
if n.quiet {
return nil
}
prog, task, err := parser.ParseTaskStarted(e)
if err != nil {
log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event")
}
var aux string
stage := prog.Stage()
switch {
case task.Context != "":
aux = task.Context
case stage != "":
aux = stage
}
if aux != "" {
aux = n.format.Aux.Render(aux)
}
n.writeToStderr(n.format.Title.Render(task.Title.Default) + aux)
case event.Notification:
if n.quiet {
return nil
}
_, text, err := parser.ParseNotification(e)
if err != nil {
log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event")
}
n.writeToStderr(n.format.Notification.Render(text))
case event.Report:
if n.quiet {
return nil
}
_, text, err := parser.ParseReport(e)
if err != nil {
log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event")
}
n.writeToStderr("")
n.writeToStdout(text)
case event.ExploreAnalysis:
analysis, content, err := parser.ParseExploreAnalysis(e)
if err != nil {
log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event")
}
// ensure the logger will not interfere with the UI
log.Set(discard.New())
return app.Run(
// TODO: this is not plumbed through from the command object...
context.Background(),
v1.Config{
Content: content,
Analysis: analysis,
Preferences: n.cfg,
},
)
}
return nil
}
func (n *V1UI) writeToStdout(s string) {
fmt.Fprintln(n.out, s)
}
func (n *V1UI) writeToStderr(s string) {
if n.quiet || n.verbosity > 0 {
// we've been told to not report anything or that we're in verbose mode thus the logger should report all info.
// This only applies to status like info on stderr, not to primary reports on stdout.
return
}
fmt.Fprintln(n.err, s)
}
func (n V1UI) Teardown(_ bool) error {
return nil
}

View file

@ -0,0 +1,136 @@
package app
import (
"errors"
"github.com/awesome-gocui/gocui"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/layout"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/layout/compound"
"golang.org/x/net/context"
"time"
)
const debug = false
type app struct {
gui *gocui.Gui
controller *controller
layout *layout.Manager
}
// Run is the UI entrypoint.
func Run(ctx context.Context, c v1.Config) error {
var err error
// it appears there is a race condition where termbox.Init() will
// block nearly indefinitely when running as the first process in
// a Docker container when started within ~25ms of container startup.
// I can't seem to determine the exact root cause, however, a large
// enough sleep will prevent this behavior (todo: remove this hack)
time.Sleep(100 * time.Millisecond)
g, err := gocui.NewGui(gocui.OutputNormal, true)
if err != nil {
return err
}
defer g.Close()
_, err = newApp(ctx, g, c)
if err != nil {
return err
}
k, mod := gocui.MustParse("Ctrl+Z")
if err := g.SetKeybinding("", k, mod, handle_ctrl_z); err != nil {
return err
}
if err := g.MainLoop(); err != nil && !errors.Is(err, gocui.ErrQuit) {
return err
}
return nil
}
func newApp(ctx context.Context, gui *gocui.Gui, cfg v1.Config) (*app, error) {
var err error
var c *controller
var globalHelpKeys []*key.Binding
c, err = newController(ctx, gui, cfg)
if err != nil {
return nil, err
}
// note: order matters when adding elements to the layout
lm := layout.NewManager()
lm.Add(c.views.Status, layout.LocationFooter)
lm.Add(c.views.Filter, layout.LocationFooter)
lm.Add(compound.NewLayerDetailsCompoundLayout(c.views.Layer, c.views.LayerDetails, c.views.ImageDetails), layout.LocationColumn)
lm.Add(c.views.Tree, layout.LocationColumn)
// todo: access this more programmatically
if debug {
lm.Add(c.views.Debug, layout.LocationColumn)
}
gui.Cursor = false
// g.Mouse = true
gui.SetManagerFunc(lm.Layout)
a := &app{
gui: gui,
controller: c,
layout: lm,
}
var infos = []key.BindingInfo{
{
Config: cfg.Preferences.KeyBindings.Global.Quit,
OnAction: a.quit,
Display: "Quit",
},
{
Config: cfg.Preferences.KeyBindings.Global.ToggleView,
OnAction: c.ToggleView,
Display: "Switch view",
},
{
Config: cfg.Preferences.KeyBindings.Navigation.Right,
OnAction: c.NextPane,
},
{
Config: cfg.Preferences.KeyBindings.Navigation.Left,
OnAction: c.PrevPane,
},
{
Config: cfg.Preferences.KeyBindings.Global.FilterFiles,
OnAction: c.ToggleFilterView,
IsSelected: c.views.Filter.IsVisible,
Display: "Filter",
},
{
Config: cfg.Preferences.KeyBindings.Global.CloseFilterFiles,
OnAction: c.CloseFilterView,
},
}
globalHelpKeys, err = key.GenerateBindings(gui, "", infos)
if err != nil {
return nil, err
}
c.views.Status.AddHelpKeys(globalHelpKeys...)
// perform the first update and render now that all resources have been loaded
err = c.UpdateAndRender()
if err != nil {
return nil, err
}
return a, err
}
// quit is the gocui callback invoked when the user hits Ctrl+C
func (a *app) quit() error {
return gocui.ErrQuit
}

View file

@ -1,52 +1,51 @@
package ui
package app
import (
"fmt"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/view"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel"
"golang.org/x/net/context"
"regexp"
"github.com/awesome-gocui/gocui"
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/runtime/ui/view"
"github.com/wagoodman/dive/runtime/ui/viewmodel"
)
type Controller struct {
gui *gocui.Gui
views *view.Views
resolver image.Resolver
imageName string
type controller struct {
gui *gocui.Gui
views *view.Views
config v1.Config
ctx context.Context // TODO: storing context in the controller is not ideal
}
func NewCollection(g *gocui.Gui, imageName string, resolver image.Resolver, analysis *image.AnalysisResult, cache filetree.Comparer) (*Controller, error) {
views, err := view.NewViews(g, imageName, analysis, cache)
func newController(ctx context.Context, g *gocui.Gui, cfg v1.Config) (*controller, error) {
views, err := view.NewViews(g, cfg)
if err != nil {
return nil, err
}
controller := &Controller{
gui: g,
views: views,
resolver: resolver,
imageName: imageName,
c := &controller{
gui: g,
views: views,
config: cfg,
ctx: ctx,
}
// layer view cursor down event should trigger an update in the file tree
controller.views.Layer.AddLayerChangeListener(controller.onLayerChange)
c.views.Layer.AddLayerChangeListener(c.onLayerChange)
// update the status pane when a filetree option is changed by the user
controller.views.Tree.AddViewOptionChangeListener(controller.onFileTreeViewOptionChange)
c.views.Tree.AddViewOptionChangeListener(c.onFileTreeViewOptionChange)
// update the status pane when a filetree option is changed by the user
controller.views.Tree.AddViewExtractListener(controller.onFileTreeViewExtract)
c.views.Tree.AddViewExtractListener(c.onFileTreeViewExtract)
// update the tree view while the user types into the filter view
controller.views.Filter.AddFilterEditListener(controller.onFilterEdit)
c.views.Filter.AddFilterEditListener(c.onFilterEdit)
// propagate initial conditions to necessary views
err = controller.onLayerChange(viewmodel.LayerSelection{
Layer: controller.views.Layer.CurrentLayer(),
err = c.onLayerChange(viewmodel.LayerSelection{
Layer: c.views.Layer.CurrentLayer(),
BottomTreeStart: 0,
BottomTreeStop: 0,
TopTreeStart: 0,
@ -57,14 +56,14 @@ func NewCollection(g *gocui.Gui, imageName string, resolver image.Resolver, anal
return nil, err
}
return controller, nil
return c, nil
}
func (c *Controller) onFileTreeViewExtract(p string) error {
return c.resolver.Extract(c.imageName, c.views.LayerDetails.CurrentLayer.Id, p)
func (c *controller) onFileTreeViewExtract(p string) error {
return c.config.Content.Extract(c.ctx, c.config.Analysis.Image, c.views.LayerDetails.CurrentLayer.Id, p)
}
func (c *Controller) onFileTreeViewOptionChange() error {
func (c *controller) onFileTreeViewOptionChange() error {
err := c.views.Status.Update()
if err != nil {
return err
@ -72,7 +71,7 @@ func (c *Controller) onFileTreeViewOptionChange() error {
return c.views.Status.Render()
}
func (c *Controller) onFilterEdit(filter string) error {
func (c *controller) onFilterEdit(filter string) error {
var filterRegex *regexp.Regexp
var err error
@ -93,7 +92,7 @@ func (c *Controller) onFilterEdit(filter string) error {
return c.views.Tree.Render()
}
func (c *Controller) onLayerChange(selection viewmodel.LayerSelection) error {
func (c *controller) onLayerChange(selection viewmodel.LayerSelection) error {
// update the details
c.views.LayerDetails.CurrentLayer = selection.Layer
@ -113,39 +112,36 @@ func (c *Controller) onLayerChange(selection viewmodel.LayerSelection) error {
return c.UpdateAndRender()
}
func (c *Controller) UpdateAndRender() error {
func (c *controller) UpdateAndRender() error {
err := c.Update()
if err != nil {
logrus.Debug("failed update: ", err)
return err
return fmt.Errorf("controller failed update: %w", err)
}
err = c.Render()
if err != nil {
logrus.Debug("failed render: ", err)
return err
return fmt.Errorf("controller failed render: %w", err)
}
return nil
}
// Update refreshes the state objects for future rendering.
func (c *Controller) Update() error {
for _, controller := range c.views.All() {
err := controller.Update()
func (c *controller) Update() error {
for _, v := range c.views.Renderers() {
err := v.Update()
if err != nil {
logrus.Debug("unable to update controller: ")
return err
return fmt.Errorf("controller unable to update view: %w", err)
}
}
return nil
}
// Render flushes the state objects to the screen.
func (c *Controller) Render() error {
for _, controller := range c.views.All() {
if controller.IsVisible() {
err := controller.Render()
func (c *controller) Render() error {
for _, v := range c.views.Renderers() {
if v.IsVisible() {
err := v.Render()
if err != nil {
return err
}
@ -155,10 +151,10 @@ func (c *Controller) Render() error {
}
//nolint:dupl
func (c *Controller) NextPane() (err error) {
func (c *controller) NextPane() (err error) {
v := c.gui.CurrentView()
if v == nil {
panic("Current view is nil")
panic("CurrentView is nil")
}
if v.Name() == c.views.Layer.Name() {
_, err = c.gui.SetCurrentView(c.views.LayerDetails.Name())
@ -172,15 +168,14 @@ func (c *Controller) NextPane() (err error) {
}
if err != nil {
logrus.Error("unable to toggle view: ", err)
return err
return fmt.Errorf("controller unable to switch to next pane: %w", err)
}
return c.UpdateAndRender()
}
//nolint:dupl
func (c *Controller) PrevPane() (err error) {
func (c *controller) PrevPane() (err error) {
v := c.gui.CurrentView()
if v == nil {
panic("Current view is nil")
@ -197,15 +192,14 @@ func (c *Controller) PrevPane() (err error) {
}
if err != nil {
logrus.Error("unable to toggle view: ", err)
return err
return fmt.Errorf("controller unable to switch to previous pane: %w", err)
}
return c.UpdateAndRender()
}
// ToggleView switches between the file view and the layer view and re-renders the screen.
func (c *Controller) ToggleView() (err error) {
func (c *controller) ToggleView() (err error) {
v := c.gui.CurrentView()
if v == nil || v.Name() == c.views.Layer.Name() {
_, err = c.gui.SetCurrentView(c.views.Tree.Name())
@ -216,14 +210,13 @@ func (c *Controller) ToggleView() (err error) {
}
if err != nil {
logrus.Error("unable to toggle view: ", err)
return err
return fmt.Errorf("controller unable to toggle view: %w", err)
}
return c.UpdateAndRender()
}
func (c *Controller) CloseFilterView() error {
func (c *controller) CloseFilterView() error {
// filter view needs to be visible
if c.views.Filter.IsVisible() {
// toggle filter view
@ -232,12 +225,11 @@ func (c *Controller) CloseFilterView() error {
return nil
}
func (c *Controller) ToggleFilterView() error {
func (c *controller) ToggleFilterView() error {
// delete all user input from the tree view
err := c.views.Filter.ToggleVisible()
if err != nil {
logrus.Error("unable to toggle filter visibility: ", err)
return err
return fmt.Errorf("unable to toggle filter visibility: %w", err)
}
// we have just hidden the filter view...
@ -248,8 +240,7 @@ func (c *Controller) ToggleFilterView() error {
// ...adjust focus to a valid (visible) view
err = c.ToggleView()
if err != nil {
logrus.Error("unable to toggle filter view (back): ", err)
return err
return fmt.Errorf("unable to toggle filter view (back): %w", err)
}
}

View file

@ -1,7 +1,7 @@
//go:build windows
// +build windows
package ui
package app
import (
"github.com/awesome-gocui/gocui"

View file

@ -1,7 +1,7 @@
//go:build !windows
// +build !windows
package ui
package app
import (
"syscall"

View file

@ -0,0 +1,67 @@
package v1
import (
"errors"
"fmt"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key"
"github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/dive/image"
"golang.org/x/net/context"
"sync"
)
type Config struct {
// required input
Analysis image.Analysis
Content ContentReader
Preferences Preferences
stack filetree.Comparer
stackErrs error
do *sync.Once
}
type Preferences struct {
KeyBindings key.Bindings
IgnoreErrors bool
ShowFiletreeAttributes bool
ShowAggregatedLayerChanges bool
CollapseFiletreeDirectory bool
FiletreePaneWidth float64
FiletreeDiffHide []string
}
func DefaultPreferences() Preferences {
return Preferences{
KeyBindings: key.DefaultBindings(),
ShowFiletreeAttributes: true,
ShowAggregatedLayerChanges: true,
CollapseFiletreeDirectory: false, // don't start with collapsed directories
FiletreePaneWidth: 0.5,
FiletreeDiffHide: []string{}, // empty slice means show all
}
}
func (c *Config) TreeComparer() (filetree.Comparer, error) {
if c.do == nil {
c.do = &sync.Once{}
}
c.do.Do(func() {
treeStack := filetree.NewComparer(c.Analysis.RefTrees)
errs := treeStack.BuildCache()
if errs != nil {
if !c.Preferences.IgnoreErrors {
errs = append(errs, fmt.Errorf("file tree has path errors (use '--ignore-errors' to attempt to continue)"))
c.stackErrs = errors.Join(errs...)
return
}
}
c.stack = treeStack
})
return c.stack, c.stackErrs
}
type ContentReader interface {
Extract(ctx context.Context, id string, layer string, path string) error
}

View file

@ -2,19 +2,15 @@ package key
import (
"fmt"
"github.com/awesome-gocui/gocui"
"github.com/awesome-gocui/keybinding"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format"
)
type BindingInfo struct {
Key gocui.Key
Modifier gocui.Modifier
ConfigKeys []string
Config Config
OnAction func() error
IsSelected func() bool
Display string
@ -30,15 +26,12 @@ type Binding struct {
func GenerateBindings(gui *gocui.Gui, influence string, infos []BindingInfo) ([]*Binding, error) {
var result = make([]*Binding, 0)
for _, info := range infos {
var err error
var binding *Binding
if len(info.ConfigKeys) > 0 {
binding, err = NewBindingFromConfig(gui, influence, info.ConfigKeys, info.Display, info.OnAction)
} else {
binding, err = NewBinding(gui, influence, info.Key, info.Modifier, info.Display, info.OnAction)
if len(info.Config.Keys) == 0 {
return nil, fmt.Errorf("no keybinding configured for '%s'", info.Display)
}
binding, err := newBinding(gui, influence, info.Config.Keys, info.Display, info.OnAction)
if err != nil {
return nil, err
}
@ -53,37 +46,6 @@ func GenerateBindings(gui *gocui.Gui, influence string, infos []BindingInfo) ([]
return result, nil
}
func NewBinding(gui *gocui.Gui, influence string, key gocui.Key, mod gocui.Modifier, displayName string, actionFn func() error) (*Binding, error) {
return newBinding(gui, influence, []keybinding.Key{{Value: key, Modifier: mod}}, displayName, actionFn)
}
func NewBindingFromConfig(gui *gocui.Gui, influence string, configKeys []string, displayName string, actionFn func() error) (*Binding, error) {
var parsedKeys []keybinding.Key
for _, configKey := range configKeys {
bindStr := viper.GetString(configKey)
if bindStr == "" {
logrus.Debugf("skipping keybinding '%s' (no value given)", configKey)
continue
}
logrus.Debugf("parsing keybinding '%s' --> '%s'", configKey, bindStr)
keys, err := keybinding.ParseAll(bindStr)
if err != nil {
return nil, err
}
if len(keys) > 0 {
parsedKeys = keys
break
}
}
if parsedKeys == nil {
return nil, fmt.Errorf("could not find configured keybindings for: %+v", configKeys)
}
return newBinding(gui, influence, parsedKeys, displayName, actionFn)
}
func newBinding(gui *gocui.Gui, influence string, keys []keybinding.Key, displayName string, actionFn func() error) (*Binding, error) {
binding := &Binding{
key: keys,

View file

@ -0,0 +1,100 @@
package key
import (
"fmt"
"github.com/awesome-gocui/keybinding"
)
type Config struct {
Input string
Keys []keybinding.Key `yaml:"-" mapstructure:"-"`
}
func (c *Config) Setup() error {
if len(c.Input) == 0 {
return nil
}
parsed, err := keybinding.ParseAll(c.Input)
if err != nil {
return fmt.Errorf("failed to parse key %q: %w", c.Input, err)
}
c.Keys = parsed
return nil
}
type Bindings struct {
Global GlobalBindings `yaml:",inline" mapstructure:",squash"`
Navigation NavigationBindings `yaml:",inline" mapstructure:",squash"`
Layer LayerBindings `yaml:",inline" mapstructure:",squash"`
Filetree FiletreeBindings `yaml:",inline" mapstructure:",squash"`
}
type GlobalBindings struct {
Quit Config `yaml:"quit" mapstructure:"quit"`
ToggleView Config `yaml:"toggle-view" mapstructure:"toggle-view"`
FilterFiles Config `yaml:"filter-files" mapstructure:"filter-files"`
CloseFilterFiles Config `yaml:"close-filter-files" mapstructure:"close-filter-files"`
}
type NavigationBindings struct {
Up Config `yaml:"up" mapstructure:"up"`
Down Config `yaml:"down" mapstructure:"down"`
Left Config `yaml:"left" mapstructure:"left"`
Right Config `yaml:"right" mapstructure:"right"`
PageUp Config `yaml:"page-up" mapstructure:"page-up"`
PageDown Config `yaml:"page-down" mapstructure:"page-down"`
}
type LayerBindings struct {
CompareAll Config `yaml:"compare-all" mapstructure:"compare-all"`
CompareLayer Config `yaml:"compare-layer" mapstructure:"compare-layer"`
}
type FiletreeBindings struct {
ToggleCollapseDir Config `yaml:"toggle-collapse-dir" mapstructure:"toggle-collapse-dir"`
ToggleCollapseAllDir Config `yaml:"toggle-collapse-all-dir" mapstructure:"toggle-collapse-all-dir"`
ToggleAddedFiles Config `yaml:"toggle-added-files" mapstructure:"toggle-added-files"`
ToggleRemovedFiles Config `yaml:"toggle-removed-files" mapstructure:"toggle-removed-files"`
ToggleModifiedFiles Config `yaml:"toggle-modified-files" mapstructure:"toggle-modified-files"`
ToggleUnmodifiedFiles Config `yaml:"toggle-unmodified-files" mapstructure:"toggle-unmodified-files"`
ToggleTreeAttributes Config `yaml:"toggle-filetree-attributes" mapstructure:"toggle-filetree-attributes"`
ToggleSortOrder Config `yaml:"toggle-sort-order" mapstructure:"toggle-sort-order"`
ToggleWrapTree Config `yaml:"toggle-wrap-tree" mapstructure:"toggle-wrap-tree"`
ExtractFile Config `yaml:"extract-file" mapstructure:"extract-file"`
}
func DefaultBindings() Bindings {
return Bindings{
Global: GlobalBindings{
Quit: Config{Input: "ctrl+c"},
ToggleView: Config{Input: "tab"},
FilterFiles: Config{Input: "ctrl+f, ctrl+slash"},
CloseFilterFiles: Config{Input: "esc"},
},
Navigation: NavigationBindings{
Up: Config{Input: "up,k"},
Down: Config{Input: "down,j"},
Left: Config{Input: "left,h"},
Right: Config{Input: "right,l"},
PageUp: Config{Input: "pgup,u"},
PageDown: Config{Input: "pgdn,d"},
},
Layer: LayerBindings{
CompareAll: Config{Input: "ctrl+a"},
CompareLayer: Config{Input: "ctrl+l"},
},
Filetree: FiletreeBindings{
ToggleCollapseDir: Config{Input: "space"},
ToggleCollapseAllDir: Config{Input: "ctrl+space"},
ToggleAddedFiles: Config{Input: "ctrl+a"},
ToggleRemovedFiles: Config{Input: "ctrl+r"},
ToggleModifiedFiles: Config{Input: "ctrl+m"},
ToggleUnmodifiedFiles: Config{Input: "ctrl+u"},
ToggleTreeAttributes: Config{Input: "ctrl+b"},
ToggleWrapTree: Config{Input: "ctrl+p"},
ToggleSortOrder: Config{Input: "ctrl+o"},
ExtractFile: Config{Input: "ctrl+e"},
},
}
}

View file

@ -1,11 +1,11 @@
package compound
import (
"fmt"
"github.com/awesome-gocui/gocui"
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/runtime/ui/view"
"github.com/wagoodman/dive/utils"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/view"
"github.com/wagoodman/dive/internal/log"
"github.com/wagoodman/dive/internal/utils"
)
type LayerDetailsCompoundLayout struct {
@ -31,26 +31,23 @@ func (cl *LayerDetailsCompoundLayout) Name() string {
func (cl *LayerDetailsCompoundLayout) OnLayoutChange() error {
err := cl.layer.OnLayoutChange()
if err != nil {
logrus.Error("unable to setup layer controller onLayoutChange", err)
return err
return fmt.Errorf("unable to setup layer controller onLayoutChange: %w", err)
}
err = cl.layerDetails.OnLayoutChange()
if err != nil {
logrus.Error("unable to setup layer details controller onLayoutChange", err)
return err
return fmt.Errorf("unable to setup layer details controller onLayoutChange: %w", err)
}
err = cl.imageDetails.OnLayoutChange()
if err != nil {
logrus.Error("unable to setup image details controller onLayoutChange", err)
return err
return fmt.Errorf("unable to setup image details controller onLayoutChange: %w", err)
}
return nil
}
func (cl *LayerDetailsCompoundLayout) layoutRow(g *gocui.Gui, minX, minY, maxX, maxY int, viewName string, setup func(*gocui.View, *gocui.View) error) error {
logrus.Tracef("layoutRow(g, minX: %d, minY: %d, maxX: %d, maxY: %d, viewName: %s, <setup func>)", minX, minY, maxX, maxY, viewName)
log.WithFields("ui", cl.Name()).Tracef("layoutRow(g, minX: %d, minY: %d, maxX: %d, maxY: %d, viewName: %s, <setup func>)", minX, minY, maxX, maxY, viewName)
// header + border
headerHeight := 2
@ -64,17 +61,16 @@ func (cl *LayerDetailsCompoundLayout) layoutRow(g *gocui.Gui, minX, minY, maxX,
if utils.IsNewView(bodyErr, headerErr) {
err := setup(bodyView, headerView)
if err != nil {
logrus.Debug("unable to setup row layout for ", viewName, err)
return err
return fmt.Errorf("unable to setup row layout for %s: %w", viewName, err)
}
}
return nil
}
func (cl *LayerDetailsCompoundLayout) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error {
logrus.Tracef("LayerDetailsCompoundLayout.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, cl.Name())
log.WithFields("ui", cl.Name()).Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY)
layouts := []view.IView{
layouts := []view.View{
cl.layer,
cl.layerDetails,
cl.imageDetails,
@ -83,15 +79,13 @@ func (cl *LayerDetailsCompoundLayout) Layout(g *gocui.Gui, minX, minY, maxX, max
rowHeight := maxY / 3
for i := 0; i < 3; i++ {
if err := cl.layoutRow(g, minX, i*rowHeight, maxX, (i+1)*rowHeight, layouts[i].Name(), layouts[i].Setup); err != nil {
logrus.Debug("Laying out layers view errored!")
return err
return fmt.Errorf("unable to layout %q: %w", layouts[i].Name(), err)
}
}
if g.CurrentView() == nil {
if _, err := g.SetCurrentView(cl.layer.Name()); err != nil {
logrus.Error("unable to set view to layer", err)
return err
return fmt.Errorf("unable to set view to layer %q: %w", cl.layer.Name(), err)
}
}
return nil

View file

@ -1,8 +1,9 @@
package layout
import (
"fmt"
"github.com/awesome-gocui/gocui"
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/internal/log"
)
type Constraint func(int) int
@ -45,7 +46,7 @@ func (lm *Manager) planAndLayoutHeaders(g *gocui.Gui, area Area) (Area, error) {
// layout the header within the allocated space
err := element.Layout(g, area.minX, area.minY, area.maxX, area.minY+height)
if err != nil {
logrus.Errorf("failed to layout '%s' header: %+v", element.Name(), err)
log.WithFields("element", element.Name(), "error", err).Error("failed to layout header")
return area, err
}
@ -134,8 +135,7 @@ func (lm *Manager) planAndLayoutColumns(g *gocui.Gui, area Area) (Area, error) {
// layout the column within the allocated space
err := element.Layout(g, area.minX, area.minY, area.minX+width, area.maxY)
if err != nil {
logrus.Errorf("failed to layout '%s' column: %+v", element.Name(), err)
return area, err
return area, fmt.Errorf("failed to layout '%s' column: %w", element.Name(), err)
}
// move left to right, scratching off real estate as it is taken
@ -164,8 +164,7 @@ func (lm *Manager) layoutFooters(g *gocui.Gui, area Area, footerHeights []int) e
// do the same vertically, thus a -1 is needed for a starting Y
err := element.Layout(g, area.minX, topY, area.maxX, bottomY)
if err != nil {
logrus.Errorf("failed to layout '%s' footer: %+v", element.Name(), err)
return err
return fmt.Errorf("failed to layout %q footer: %w", element.Name(), err)
}
}
}

View file

@ -2,12 +2,11 @@ package view
import (
"fmt"
"github.com/anchore/go-logger"
"github.com/awesome-gocui/gocui"
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/utils"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format"
"github.com/wagoodman/dive/internal/log"
"github.com/wagoodman/dive/internal/utils"
)
// Debug is just for me :)
@ -16,19 +15,21 @@ type Debug struct {
gui *gocui.Gui
view *gocui.View
header *gocui.View
logger logger.Logger
selectedView Helper
}
// newDebugView creates a new view object attached the global [gocui] screen object.
func newDebugView(gui *gocui.Gui) (controller *Debug) {
controller = new(Debug)
func newDebugView(gui *gocui.Gui) *Debug {
c := new(Debug)
// populate main fields
controller.name = "debug"
controller.gui = gui
c.name = "debug"
c.gui = gui
c.logger = log.Nested("ui", "debug")
return controller
return c
}
func (v *Debug) SetCurrentView(r Helper) {
@ -41,7 +42,7 @@ func (v *Debug) Name() string {
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (v *Debug) Setup(view *gocui.View, header *gocui.View) error {
logrus.Tracef("view.Setup() %s", v.Name())
v.logger.Trace("setup()")
// set controller options
v.view = view
@ -78,7 +79,7 @@ func (v *Debug) OnLayoutChange() error {
// Render flushes the state objects to the screen.
func (v *Debug) Render() error {
logrus.Tracef("view.Render() %s", v.Name())
v.logger.Trace("render()")
v.gui.Update(func(g *gocui.Gui) error {
// update header...
@ -91,7 +92,7 @@ func (v *Debug) Render() error {
v.view.Clear()
_, err := fmt.Fprintln(v.view, "blerg")
if err != nil {
logrus.Debug("unable to write to buffer: ", err)
v.logger.WithFields("error", err).Debug("unable to write to buffer")
}
return nil
@ -100,7 +101,7 @@ func (v *Debug) Render() error {
}
func (v *Debug) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error {
logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name())
v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY)
// header
headerSize := 1
@ -112,8 +113,7 @@ func (v *Debug) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error {
if utils.IsNewView(viewErr, headerErr) {
err := v.Setup(view, header)
if err != nil {
logrus.Error("unable to setup debug controller", err)
return err
return fmt.Errorf("unable to setup debug controller: %w", err)
}
}
return nil

View file

@ -2,24 +2,24 @@ package view
import (
"fmt"
"github.com/anchore/go-logger"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"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/cmd/dive/cli/internal/ui/v1/viewmodel"
"github.com/wagoodman/dive/internal/log"
"github.com/wagoodman/dive/internal/utils"
"regexp"
"github.com/awesome-gocui/gocui"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/runtime/ui/key"
"github.com/wagoodman/dive/runtime/ui/viewmodel"
"github.com/wagoodman/dive/utils"
)
type ViewOptionChangeListener func() error
type ViewExtractListener func(string) error
// FileTree holds the UI objects and data models for populating the right pane. Specifically the pane that
// FileTree holds the UI objects and data models for populating the right pane. Specifically, the pane that
// shows selected layer or aggregate file ASCII tree.
type FileTree struct {
name string
@ -28,6 +28,8 @@ type FileTree struct {
header *gocui.View
vm *viewmodel.FileTreeViewModel
title string
kb key.Bindings
logger logger.Logger
filterRegex *regexp.Regexp
listeners []ViewOptionChangeListener
@ -37,26 +39,29 @@ type FileTree struct {
}
// newFileTreeView creates a new view object attached the global [gocui] screen object.
func newFileTreeView(gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.Comparer) (controller *FileTree, err error) {
controller = new(FileTree)
controller.listeners = make([]ViewOptionChangeListener, 0)
func newFileTreeView(gui *gocui.Gui, cfg v1.Config, initial int) (v *FileTree, err error) {
v = new(FileTree)
v.logger = log.Nested("ui", "filetree")
v.listeners = make([]ViewOptionChangeListener, 0)
// populate main fields
controller.name = "filetree"
controller.gui = gui
controller.vm, err = viewmodel.NewFileTreeViewModel(tree, refTrees, cache)
v.name = "filetree"
v.gui = gui
v.kb = cfg.Preferences.KeyBindings
v.vm, err = viewmodel.NewFileTreeViewModel(cfg, initial)
if err != nil {
return nil, err
}
requestedWidthRatio := viper.GetFloat64("filetree.pane-width")
requestedWidthRatio := cfg.Preferences.FiletreePaneWidth
if requestedWidthRatio >= 1 || requestedWidthRatio <= 0 {
logrus.Errorf("invalid config value: 'filetree.pane-width' should be 0 < value < 1, given '%v'", requestedWidthRatio)
v.logger.Warnf("invalid config value: 'filetree.pane-width' should be 0 < value < 1, given '%v'", requestedWidthRatio)
requestedWidthRatio = 0.5
}
controller.requestedWidthRatio = requestedWidthRatio
v.requestedWidthRatio = requestedWidthRatio
return controller, err
return v, err
}
func (v *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListener) {
@ -81,7 +86,7 @@ func (v *FileTree) Name() string {
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (v *FileTree) Setup(view, header *gocui.View) error {
logrus.Tracef("view.Setup() %s", v.Name())
log.Trace("setup()")
// set controller options
v.view = view
@ -96,88 +101,88 @@ func (v *FileTree) Setup(view, header *gocui.View) error {
var infos = []key.BindingInfo{
{
ConfigKeys: []string{"keybinding.toggle-collapse-dir"},
OnAction: v.toggleCollapse,
Display: "Collapse dir",
Config: v.kb.Filetree.ToggleCollapseDir,
OnAction: v.toggleCollapse,
Display: "Collapse dir",
},
{
ConfigKeys: []string{"keybinding.toggle-collapse-all-dir"},
OnAction: v.toggleCollapseAll,
Display: "Collapse all dir",
Config: v.kb.Filetree.ToggleCollapseAllDir,
OnAction: v.toggleCollapseAll,
Display: "Collapse all dir",
},
{
ConfigKeys: []string{"keybinding.toggle-sort-order"},
OnAction: v.toggleSortOrder,
Display: "Toggle sort order",
Config: v.kb.Filetree.ToggleSortOrder,
OnAction: v.toggleSortOrder,
Display: "Toggle sort order",
},
{
ConfigKeys: []string{"keybinding.extract-file"},
OnAction: v.extractFile,
Display: "Extract File",
Config: v.kb.Filetree.ExtractFile,
OnAction: v.extractFile,
Display: "Extract File",
},
{
ConfigKeys: []string{"keybinding.toggle-added-files"},
Config: v.kb.Filetree.ToggleAddedFiles,
OnAction: func() error { return v.toggleShowDiffType(filetree.Added) },
IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Added] },
Display: "Added",
},
{
ConfigKeys: []string{"keybinding.toggle-removed-files"},
Config: v.kb.Filetree.ToggleRemovedFiles,
OnAction: func() error { return v.toggleShowDiffType(filetree.Removed) },
IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Removed] },
Display: "Removed",
},
{
ConfigKeys: []string{"keybinding.toggle-modified-files"},
Config: v.kb.Filetree.ToggleModifiedFiles,
OnAction: func() error { return v.toggleShowDiffType(filetree.Modified) },
IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Modified] },
Display: "Modified",
},
{
ConfigKeys: []string{"keybinding.toggle-unchanged-files", "keybinding.toggle-unmodified-files"},
Config: v.kb.Filetree.ToggleUnmodifiedFiles,
OnAction: func() error { return v.toggleShowDiffType(filetree.Unmodified) },
IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Unmodified] },
Display: "Unmodified",
},
{
ConfigKeys: []string{"keybinding.toggle-filetree-attributes"},
Config: v.kb.Filetree.ToggleTreeAttributes,
OnAction: v.toggleAttributes,
IsSelected: func() bool { return v.vm.ShowAttributes },
Display: "Attributes",
},
{
ConfigKeys: []string{"keybinding.toggle-wrap-tree"},
Config: v.kb.Filetree.ToggleWrapTree,
OnAction: v.toggleWrapTree,
IsSelected: func() bool { return v.view.Wrap },
Display: "Wrap",
},
{
ConfigKeys: []string{"keybinding.page-up"},
OnAction: v.PageUp,
Config: v.kb.Navigation.PageUp,
OnAction: v.PageUp,
},
{
ConfigKeys: []string{"keybinding.page-down"},
OnAction: v.PageDown,
Config: v.kb.Navigation.PageDown,
OnAction: v.PageDown,
},
{
ConfigKeys: []string{"keybinding.down"},
Modifier: gocui.ModNone,
OnAction: v.CursorDown,
Config: v.kb.Navigation.Down,
Modifier: gocui.ModNone,
OnAction: v.CursorDown,
},
{
ConfigKeys: []string{"keybinding.up"},
Modifier: gocui.ModNone,
OnAction: v.CursorUp,
Config: v.kb.Navigation.Up,
Modifier: gocui.ModNone,
OnAction: v.CursorUp,
},
{
ConfigKeys: []string{"keybinding.left"},
Modifier: gocui.ModNone,
OnAction: v.CursorLeft,
Config: v.kb.Navigation.Left,
Modifier: gocui.ModNone,
OnAction: v.CursorLeft,
},
{
ConfigKeys: []string{"keybinding.right"},
Modifier: gocui.ModNone,
OnAction: v.CursorRight,
Config: v.kb.Navigation.Right,
Modifier: gocui.ModNone,
OnAction: v.CursorRight,
},
}
@ -347,8 +352,7 @@ func (v *FileTree) notifyOnViewOptionChangeListeners() error {
for _, listener := range v.listeners {
err := listener()
if err != nil {
logrus.Errorf("notifyOnViewOptionChangeListeners error: %+v", err)
return err
return fmt.Errorf("notifyOnViewOptionChangeListeners error: %w", err)
}
}
return nil
@ -416,7 +420,7 @@ func (v *FileTree) Update() error {
// Render flushes the state objects (file tree) to the pane.
func (v *FileTree) Render() error {
logrus.Tracef("view.Render() %s", v.Name())
v.logger.Trace("render()")
title := v.title
isSelected := v.gui.CurrentView() == v.view
@ -454,7 +458,7 @@ func (v *FileTree) KeyHelp() string {
}
func (v *FileTree) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error {
logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name())
v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY)
attributeRowSize := 0
// make the layout responsive to the available realestate. Make more room for the main content by hiding auxiliary
@ -479,8 +483,7 @@ func (v *FileTree) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error {
if utils.IsNewView(viewErr, headerErr) {
err := v.Setup(view, header)
if err != nil {
logrus.Error("unable to setup tree controller", err)
return err
return fmt.Errorf("unable to setup tree controller: %w", err)
}
}
return nil

View file

@ -2,13 +2,13 @@ package view
import (
"fmt"
"github.com/anchore/go-logger"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format"
"github.com/wagoodman/dive/internal/log"
"github.com/wagoodman/dive/internal/utils"
"strings"
"github.com/awesome-gocui/gocui"
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/utils"
)
type FilterEditListener func(string) error
@ -16,9 +16,11 @@ type FilterEditListener func(string) error
// Filter holds the UI objects and data models for populating the bottom row. Specifically the pane that
// allows the user to filter the file tree by path.
type Filter struct {
gui *gocui.Gui
view *gocui.View
header *gocui.View
gui *gocui.Gui
view *gocui.View
header *gocui.View
logger logger.Logger
labelStr string
maxLength int
hidden bool
@ -28,19 +30,20 @@ type Filter struct {
}
// newFilterView creates a new view object attached the global [gocui] screen object.
func newFilterView(gui *gocui.Gui) (controller *Filter) {
controller = new(Filter)
func newFilterView(gui *gocui.Gui) *Filter {
c := new(Filter)
c.logger = log.Nested("ui", "filter")
controller.filterEditListeners = make([]FilterEditListener, 0)
c.filterEditListeners = make([]FilterEditListener, 0)
// populate main fields
controller.gui = gui
controller.labelStr = "Path Filter: "
controller.hidden = true
c.gui = gui
c.labelStr = "Path Filter: "
c.hidden = true
controller.requestedHeight = 1
c.requestedHeight = 1
return controller
return c
}
func (v *Filter) AddFilterEditListener(listener ...FilterEditListener) {
@ -53,7 +56,7 @@ func (v *Filter) Name() string {
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (v *Filter) Setup(view, header *gocui.View) error {
logrus.Tracef("view.Setup() %s", v.Name())
log.Trace("Setup()")
// set controller options
v.view = view
@ -83,8 +86,7 @@ func (v *Filter) ToggleVisible() error {
if !v.hidden {
_, err := v.gui.SetCurrentView(v.Name())
if err != nil {
logrus.Error("unable to toggle filter view: ", err)
return err
return fmt.Errorf("unable to toggle filter view: %w", err)
}
return nil
}
@ -131,7 +133,7 @@ func (v *Filter) notifyFilterEditListeners() {
err := listener(currentValue)
if err != nil {
// note: cannot propagate error from here since this is from the main gogui thread
logrus.Errorf("notifyFilterEditListeners: %+v", err)
v.logger.WithFields("error", err).Debug("unable to notify filter edit listeners")
}
}
}
@ -143,13 +145,10 @@ func (v *Filter) Update() error {
// Render flushes the state objects to the screen. Currently this is the users path filter input.
func (v *Filter) Render() error {
logrus.Tracef("view.Render() %s", v.Name())
v.logger.Trace("render()")
v.gui.Update(func(g *gocui.Gui) error {
_, err := fmt.Fprintln(v.header, format.Header(v.labelStr))
if err != nil {
logrus.Error("unable to write to buffer: ", err)
}
return err
})
return nil
@ -170,7 +169,7 @@ func (v *Filter) OnLayoutChange() error {
}
func (v *Filter) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error {
logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name())
v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY)
label, labelErr := g.SetView(v.Name()+"label", minX, minY, len(v.labelStr), maxY, 0)
view, viewErr := g.SetView(v.Name(), minX+(len(v.labelStr)-1), minY, maxX, maxY, 0)
@ -178,8 +177,7 @@ func (v *Filter) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error {
if utils.IsNewView(viewErr, labelErr) {
err := v.Setup(view, label)
if err != nil {
logrus.Error("unable to setup status controller", err)
return err
return fmt.Errorf("unable to setup filter controller: %w", err)
}
}
return nil

View file

@ -2,26 +2,29 @@ package view
import (
"fmt"
"github.com/anchore/go-logger"
"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/sirupsen/logrus"
"github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/runtime/ui/key"
)
type ImageDetails struct {
gui *gocui.Gui
body *gocui.View
header *gocui.View
gui *gocui.Gui
body *gocui.View
header *gocui.View
logger logger.Logger
imageName string
imageSize uint64
efficiency float64
inefficiencies filetree.EfficiencySlice
kb key.Bindings
}
func (v *ImageDetails) Name() string {
@ -29,7 +32,9 @@ func (v *ImageDetails) Name() string {
}
func (v *ImageDetails) Setup(body, header *gocui.View) error {
logrus.Tracef("ImageDetails setup()")
v.logger = log.Nested("ui", "imageDetails")
v.logger.Trace("Setup()")
v.body = body
v.body.Editable = false
v.body.Wrap = true
@ -44,22 +49,22 @@ func (v *ImageDetails) Setup(body, header *gocui.View) error {
var infos = []key.BindingInfo{
{
ConfigKeys: []string{"keybinding.down"},
Modifier: gocui.ModNone,
OnAction: v.CursorDown,
Config: v.kb.Navigation.Down,
Modifier: gocui.ModNone,
OnAction: v.CursorDown,
},
{
ConfigKeys: []string{"keybinding.up"},
Modifier: gocui.ModNone,
OnAction: v.CursorUp,
Config: v.kb.Navigation.Up,
Modifier: gocui.ModNone,
OnAction: v.CursorUp,
},
{
ConfigKeys: []string{"keybinding.page-up"},
OnAction: v.PageUp,
Config: v.kb.Navigation.PageUp,
OnAction: v.PageUp,
},
{
ConfigKeys: []string{"keybinding.page-down"},
OnAction: v.PageDown,
Config: v.kb.Navigation.PageDown,
OnAction: v.PageDown,
},
}
@ -99,7 +104,7 @@ func (v *ImageDetails) Render() error {
v.header.Clear()
_, err := fmt.Fprintln(v.header, imageHeaderStr)
if err != nil {
logrus.Debug("unable to write to buffer: ", err)
log.WithFields("error", err).Debug("unable to write to buffer")
}
var lines = []string{
@ -114,7 +119,7 @@ func (v *ImageDetails) Render() error {
v.body.Clear()
_, err = fmt.Fprintln(v.body, strings.Join(lines, "\n"))
if err != nil {
logrus.Debug("unable to write to buffer: ", err)
log.WithFields("error", err).Debug("unable to write to buffer")
}
return err
})
@ -137,7 +142,7 @@ func (v *ImageDetails) IsVisible() bool {
func (v *ImageDetails) PageUp() error {
_, height := v.body.Size()
if err := CursorStep(v.gui, v.body, -height); err != nil {
logrus.Debugf("Couldn't move the cursor up by %d steps", height)
v.logger.WithFields("error", err).Debugf("couldn't move the cursor up by %d steps", height)
}
return nil
}
@ -145,21 +150,21 @@ func (v *ImageDetails) PageUp() error {
func (v *ImageDetails) PageDown() error {
_, height := v.body.Size()
if err := CursorStep(v.gui, v.body, height); err != nil {
logrus.Debugf("Couldn't move the cursor down by %d steps", height)
v.logger.WithFields("error", err).Debugf("couldn't move the cursor down by %d steps", height)
}
return nil
}
func (v *ImageDetails) CursorUp() error {
if err := CursorUp(v.gui, v.body); err != nil {
logrus.Debug("Couldn't move the cursor up")
v.logger.WithFields("error", err).Debug("couldn't move the cursor up")
}
return nil
}
func (v *ImageDetails) CursorDown() error {
if err := CursorDown(v.gui, v.body); err != nil {
logrus.Debug("Couldn't move the cursor down")
v.logger.WithFields("error", err).Debug("couldn't move the cursor down")
}
return nil
}

View file

@ -2,14 +2,14 @@ package view
import (
"fmt"
"github.com/anchore/go-logger"
"github.com/awesome-gocui/gocui"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"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/cmd/dive/cli/internal/ui/v1/viewmodel"
"github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/runtime/ui/key"
"github.com/wagoodman/dive/runtime/ui/viewmodel"
"github.com/wagoodman/dive/internal/log"
)
// Layer holds the UI objects and data models for populating the lower-left pane.
@ -20,6 +20,8 @@ type Layer struct {
body *gocui.View
header *gocui.View
vm *viewmodel.LayerSetState
kb key.Bindings
logger logger.Logger
constrainedRealEstate bool
listeners []LayerChangeListener
@ -28,18 +30,20 @@ type Layer struct {
}
// newLayerView creates a new view object attached the global [gocui] screen object.
func newLayerView(gui *gocui.Gui, layers []*image.Layer) (controller *Layer, err error) {
controller = new(Layer)
func newLayerView(gui *gocui.Gui, cfg v1.Config) (c *Layer, err error) {
c = new(Layer)
controller.listeners = make([]LayerChangeListener, 0)
c.logger = log.Nested("ui", "layer")
c.listeners = make([]LayerChangeListener, 0)
// populate main fields
controller.name = "layer"
controller.gui = gui
c.name = "layer"
c.gui = gui
c.kb = cfg.Preferences.KeyBindings
var compareMode viewmodel.LayerCompareMode
switch mode := viper.GetBool("layer.show-aggregated-changes"); mode {
switch mode := cfg.Preferences.ShowAggregatedLayerChanges; mode {
case true:
compareMode = viewmodel.CompareAllLayers
case false:
@ -48,9 +52,9 @@ func newLayerView(gui *gocui.Gui, layers []*image.Layer) (controller *Layer, err
return nil, fmt.Errorf("unknown layer.show-aggregated-changes value: %v", mode)
}
controller.vm = viewmodel.NewLayerSetState(layers, compareMode)
c.vm = viewmodel.NewLayerSetState(cfg.Analysis.Layers, compareMode)
return controller, err
return c, err
}
func (v *Layer) AddLayerChangeListener(listener ...LayerChangeListener) {
@ -69,14 +73,13 @@ func (v *Layer) notifyLayerChangeListeners() error {
for _, listener := range v.listeners {
err := listener(selection)
if err != nil {
logrus.Errorf("notifyLayerChangeListeners error: %+v", err)
return err
return fmt.Errorf("error notifying layer change listeners: %w", err)
}
}
// this is hacky, and I do not like it
if layerDetails, err := v.gui.View("layerDetails"); err == nil {
if err := layerDetails.SetCursor(0, 0); err != nil {
logrus.Debug("Couldn't set cursor to 0,0 for layerDetails")
v.logger.Debug("Couldn't set cursor to 0,0 for layerDetails")
}
}
return nil
@ -88,7 +91,7 @@ func (v *Layer) Name() string {
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (v *Layer) Setup(body *gocui.View, header *gocui.View) error {
logrus.Tracef("view.Setup() %s", v.Name())
v.logger.Trace("Setup()")
// set controller options
v.body = body
@ -103,34 +106,34 @@ func (v *Layer) Setup(body *gocui.View, header *gocui.View) error {
var infos = []key.BindingInfo{
{
ConfigKeys: []string{"keybinding.compare-layer"},
Config: v.kb.Layer.CompareLayer,
OnAction: func() error { return v.setCompareMode(viewmodel.CompareSingleLayer) },
IsSelected: func() bool { return v.vm.CompareMode == viewmodel.CompareSingleLayer },
Display: "Show layer changes",
},
{
ConfigKeys: []string{"keybinding.compare-all"},
Config: v.kb.Layer.CompareAll,
OnAction: func() error { return v.setCompareMode(viewmodel.CompareAllLayers) },
IsSelected: func() bool { return v.vm.CompareMode == viewmodel.CompareAllLayers },
Display: "Show aggregated changes",
},
{
ConfigKeys: []string{"keybinding.down"},
Modifier: gocui.ModNone,
OnAction: v.CursorDown,
Config: v.kb.Navigation.Down,
Modifier: gocui.ModNone,
OnAction: v.CursorDown,
},
{
ConfigKeys: []string{"keybinding.up"},
Modifier: gocui.ModNone,
OnAction: v.CursorUp,
Config: v.kb.Navigation.Up,
Modifier: gocui.ModNone,
OnAction: v.CursorUp,
},
{
ConfigKeys: []string{"keybinding.page-up"},
OnAction: v.PageUp,
Config: v.kb.Navigation.PageUp,
OnAction: v.PageUp,
},
{
ConfigKeys: []string{"keybinding.page-down"},
OnAction: v.PageDown,
Config: v.kb.Navigation.PageDown,
OnAction: v.PageDown,
},
}
@ -267,14 +270,14 @@ func (v *Layer) renderCompareBar(layerIdx int) string {
func (v *Layer) ConstrainLayout() {
if !v.constrainedRealEstate {
logrus.Debugf("constraining layer layout")
v.logger.Debug("constraining layout")
v.constrainedRealEstate = true
}
}
func (v *Layer) ExpandLayout() {
if v.constrainedRealEstate {
logrus.Debugf("expanding layer layout")
v.logger.Debug("expanding layout")
v.constrainedRealEstate = false
}
}
@ -297,7 +300,7 @@ func (v *Layer) Update() error {
// 1. the layers of the image + metadata
// 2. the current selected image
func (v *Layer) Render() error {
logrus.Tracef("view.Render() %s", v.Name())
v.logger.Trace("render()")
// indicate when selected
title := "Layers"
@ -343,7 +346,6 @@ func (v *Layer) Render() error {
}
if err != nil {
logrus.Debug("unable to write to buffer: ", err)
return err
}
}

View file

@ -0,0 +1,7 @@
package view
import (
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel"
)
type LayerChangeListener func(viewmodel.LayerSelection) error

View file

@ -2,15 +2,15 @@ package view
import (
"fmt"
"github.com/anchore/go-logger"
"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"
"strings"
"github.com/awesome-gocui/gocui"
"github.com/dustin/go-humanize"
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/runtime/ui/key"
)
type LayerDetails struct {
@ -18,6 +18,8 @@ type LayerDetails struct {
header *gocui.View
body *gocui.View
CurrentLayer *image.Layer
kb key.Bindings
logger logger.Logger
}
func (v *LayerDetails) Name() string {
@ -25,7 +27,9 @@ func (v *LayerDetails) Name() string {
}
func (v *LayerDetails) Setup(body, header *gocui.View) error {
logrus.Tracef("LayerDetails setup()")
v.logger = log.Nested("ui", "layerDetails")
v.logger.Trace("setup()")
v.body = body
v.body.Editable = false
v.body.Wrap = true
@ -40,14 +44,14 @@ func (v *LayerDetails) Setup(body, header *gocui.View) error {
var infos = []key.BindingInfo{
{
ConfigKeys: []string{"keybinding.down"},
Modifier: gocui.ModNone,
OnAction: v.CursorDown,
Config: v.kb.Navigation.Down,
Modifier: gocui.ModNone,
OnAction: v.CursorDown,
},
{
ConfigKeys: []string{"keybinding.up"},
Modifier: gocui.ModNone,
OnAction: v.CursorUp,
Config: v.kb.Navigation.Up,
Modifier: gocui.ModNone,
OnAction: v.CursorUp,
},
}
@ -95,7 +99,7 @@ func (v *LayerDetails) Render() error {
v.body.Clear()
if _, err = fmt.Fprintln(v.body, strings.Join(lines, "\n")); err != nil {
logrus.Debug("unable to write to buffer: ", err)
log.WithFields("layer", v.CurrentLayer.Id, "error", err).Debug("unable to write to buffer")
}
return nil
})
@ -117,7 +121,7 @@ func (v *LayerDetails) IsVisible() bool {
// CursorUp moves the cursor up in the details pane
func (v *LayerDetails) CursorUp() error {
if err := CursorUp(v.gui, v.body); err != nil {
logrus.Debug("Couldn't move the cursor up")
v.logger.WithFields("error", err).Debug("couldn't move the cursor up")
}
return nil
}
@ -125,7 +129,7 @@ func (v *LayerDetails) CursorUp() error {
// CursorDown moves the cursor up in the details pane
func (v *LayerDetails) CursorDown() error {
if err := CursorDown(v.gui, v.body); err != nil {
logrus.Debug("Couldn't move the cursor down")
v.logger.WithFields("error", err).Debug("couldn't move the cursor down")
}
return nil
}

View file

@ -2,22 +2,23 @@ package view
import (
"fmt"
"github.com/anchore/go-logger"
"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"
"github.com/wagoodman/dive/internal/utils"
"strings"
"github.com/awesome-gocui/gocui"
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/runtime/ui/key"
"github.com/wagoodman/dive/utils"
)
// Status holds the UI objects and data models for populating the bottom-most pane. Specifically the panel
// shows the user a set of possible actions to take in the window and currently selected pane.
type Status struct {
name string
gui *gocui.Gui
view *gocui.View
name string
gui *gocui.Gui
view *gocui.View
logger logger.Logger
selectedView Helper
requestedHeight int
@ -26,16 +27,17 @@ type Status struct {
}
// newStatusView creates a new view object attached the global [gocui] screen object.
func newStatusView(gui *gocui.Gui) (controller *Status) {
controller = new(Status)
func newStatusView(gui *gocui.Gui) *Status {
c := new(Status)
// populate main fields
controller.name = "status"
controller.gui = gui
controller.helpKeys = make([]*key.Binding, 0)
controller.requestedHeight = 1
c.name = "status"
c.gui = gui
c.helpKeys = make([]*key.Binding, 0)
c.requestedHeight = 1
c.logger = log.Nested("ui", "status")
return controller
return c
}
func (v *Status) SetCurrentView(r Helper) {
@ -52,7 +54,7 @@ func (v *Status) AddHelpKeys(keys ...*key.Binding) {
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (v *Status) Setup(view *gocui.View) error {
logrus.Tracef("view.Setup() %s", v.Name())
v.logger.Trace("setup()")
// set controller options
v.view = view
@ -82,7 +84,7 @@ func (v *Status) OnLayoutChange() error {
// Render flushes the state objects to the screen.
func (v *Status) Render() error {
logrus.Tracef("view.Render() %s", v.Name())
v.logger.Trace("render()")
v.gui.Update(func(g *gocui.Gui) error {
v.view.Clear()
@ -94,7 +96,7 @@ func (v *Status) Render() error {
_, err := fmt.Fprintln(v.view, v.KeyHelp()+selectedHelp+format.StatusNormal("▏"+strings.Repeat(" ", 1000)))
if err != nil {
logrus.Debug("unable to write to buffer: ", err)
v.logger.WithFields("error", err).Debug("unable to write to buffer")
}
return err
@ -112,14 +114,13 @@ func (v *Status) KeyHelp() string {
}
func (v *Status) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error {
logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name())
v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY)
view, viewErr := g.SetView(v.Name(), minX, minY, maxX, maxY, 0)
if utils.IsNewView(viewErr) {
err := v.Setup(view)
if err != nil {
logrus.Error("unable to setup status controller", err)
return err
return fmt.Errorf("unable to setup status controller: %w", err)
}
}
return nil

View file

@ -0,0 +1,67 @@
package view
import (
"github.com/awesome-gocui/gocui"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
)
type View interface {
Setup(*gocui.View, *gocui.View) error
Name() string
IsVisible() bool
}
type Views struct {
Tree *FileTree
Layer *Layer
Status *Status
Filter *Filter
LayerDetails *LayerDetails
ImageDetails *ImageDetails
Debug *Debug
}
func NewViews(g *gocui.Gui, cfg v1.Config) (*Views, error) {
layer, err := newLayerView(g, cfg)
if err != nil {
return nil, err
}
tree, err := newFileTreeView(g, cfg, 0)
if err != nil {
return nil, err
}
status := newStatusView(g)
// set the layer view as the first selected view
status.SetCurrentView(layer)
return &Views{
Tree: tree,
Layer: layer,
Status: status,
Filter: newFilterView(g),
ImageDetails: &ImageDetails{
gui: g,
imageName: cfg.Analysis.Image,
imageSize: cfg.Analysis.SizeBytes,
efficiency: cfg.Analysis.Efficiency,
inefficiencies: cfg.Analysis.Inefficiencies,
kb: cfg.Preferences.KeyBindings,
},
LayerDetails: &LayerDetails{gui: g, kb: cfg.Preferences.KeyBindings},
Debug: newDebugView(g),
}, nil
}
func (views *Views) Renderers() []Renderer {
return []Renderer{
views.Tree,
views.Layer,
views.Status,
views.Filter,
views.LayerDetails,
views.ImageDetails,
}
}

View file

@ -0,0 +1 @@
package viewmodel

View file

@ -3,24 +3,23 @@ package viewmodel
import (
"bytes"
"fmt"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format"
"github.com/wagoodman/dive/internal/log"
"regexp"
"strings"
"github.com/lunixbochs/vtclean"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/runtime/ui/format"
)
// FileTreeViewModel holds the UI objects and data models for populating the right pane. Specifically the pane that
// FileTreeViewModel holds the UI objects and data models for populating the right pane. Specifically, the pane that
// shows selected layer or aggregate file ASCII tree.
type FileTreeViewModel struct {
ModelTree *filetree.FileTree
ViewTree *filetree.FileTree
RefTrees []*filetree.FileTree
cache filetree.Comparer
comparer filetree.Comparer
constrainedRealEstate bool
@ -39,19 +38,24 @@ type FileTreeViewModel struct {
}
// NewFileTreeViewModel creates a new view object attached the global [gocui] screen object.
func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.Comparer) (treeViewModel *FileTreeViewModel, err error) {
func NewFileTreeViewModel(cfg v1.Config, initialLayer int) (treeViewModel *FileTreeViewModel, err error) {
treeViewModel = new(FileTreeViewModel)
comparer, err := cfg.TreeComparer()
if err != nil {
return nil, err
}
// populate main fields
treeViewModel.ShowAttributes = viper.GetBool("filetree.show-attributes")
treeViewModel.ShowAttributes = cfg.Preferences.ShowFiletreeAttributes
treeViewModel.unconstrainedShowAttributes = treeViewModel.ShowAttributes
treeViewModel.CollapseAll = viper.GetBool("filetree.collapse-dir")
treeViewModel.ModelTree = tree
treeViewModel.RefTrees = refTrees
treeViewModel.cache = cache
treeViewModel.CollapseAll = cfg.Preferences.CollapseFiletreeDirectory
treeViewModel.ModelTree = cfg.Analysis.RefTrees[initialLayer]
treeViewModel.RefTrees = cfg.Analysis.RefTrees
treeViewModel.comparer = comparer
treeViewModel.HiddenDiffTypes = make([]bool, 4)
hiddenTypes := viper.GetStringSlice("diff.hide")
hiddenTypes := cfg.Preferences.FiletreeDiffHide
for _, hType := range hiddenTypes {
switch t := strings.ToLower(hType); t {
case "added":
@ -67,7 +71,7 @@ func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree
}
}
return treeViewModel, nil
return treeViewModel, treeViewModel.SetTreeByLayer(0, 0, initialLayer, initialLayer)
}
// Setup initializes the UI concerns within the context of a global [gocui] view object.
@ -106,10 +110,9 @@ func (vm *FileTreeViewModel) SetTreeByLayer(bottomTreeStart, bottomTreeStop, top
if topTreeStop > len(vm.RefTrees)-1 {
return fmt.Errorf("invalid layer index given: %d of %d", topTreeStop, len(vm.RefTrees)-1)
}
newTree, err := vm.cache.GetTree(filetree.NewTreeIndexKey(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop))
newTree, err := vm.comparer.GetTree(filetree.NewTreeIndexKey(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop))
if err != nil {
logrus.Errorf("unable to fetch layer tree from cache: %+v", err)
return err
return fmt.Errorf("unable to fetch layer tree from cache: %w", err)
}
// preserve vm state on copy
@ -122,15 +125,14 @@ func (vm *FileTreeViewModel) SetTreeByLayer(bottomTreeStart, bottomTreeStop, top
}
err = vm.ModelTree.VisitDepthChildFirst(visitor, nil)
if err != nil {
logrus.Errorf("unable to propagate layer tree: %+v", err)
return err
return fmt.Errorf("unable to propagate layer tree: %w", err)
}
vm.ModelTree = newTree
return nil
}
// doCursorUp performs the internal view's buffer adjustments on cursor up. Note: this is independent of the gocui buffer.
// CursorUp performs the internal view's buffer adjustments on cursor up. Note: this is independent of the gocui buffer.
func (vm *FileTreeViewModel) CursorUp() bool {
if vm.TreeIndex <= 0 {
return false
@ -145,7 +147,7 @@ func (vm *FileTreeViewModel) CursorUp() bool {
return true
}
// doCursorDown performs the internal view's buffer adjustments on cursor down. Note: this is independent of the gocui buffer.
// CursorDown performs the internal view's buffer adjustments on cursor down. Note: this is independent of the gocui buffer.
func (vm *FileTreeViewModel) CursorDown() bool {
if vm.TreeIndex >= vm.ModelTree.VisibleSize() {
return false
@ -161,7 +163,6 @@ func (vm *FileTreeViewModel) CursorDown() bool {
return true
}
// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree
func (vm *FileTreeViewModel) CurrentNode(filterRegex *regexp.Regexp) *filetree.FileNode {
return vm.getAbsPositionNode(filterRegex)
}
@ -198,8 +199,7 @@ func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error {
err := vm.ModelTree.VisitDepthParentFirst(visitor, evaluator)
if err != nil {
logrus.Errorf("could not propagate tree on cursorLeft: %+v", err)
return err
return fmt.Errorf("unable to propagate tree on cursorLeft: %w", err)
}
vm.TreeIndex = newIndex
@ -320,12 +320,16 @@ func (vm *FileTreeViewModel) getAbsPositionNode(filterRegex *regexp.Regexp) (nod
match := filterRegex.Find([]byte(curNode.Path()))
regexMatch = match != nil
}
return !curNode.Parent.Data.ViewInfo.Collapsed && !curNode.Data.ViewInfo.Hidden && regexMatch
parentCollapsed := false
if curNode.Parent != nil {
parentCollapsed = curNode.Parent.Data.ViewInfo.Collapsed
}
return !parentCollapsed && !curNode.Data.ViewInfo.Hidden && regexMatch
}
err := vm.ModelTree.VisitDepthParentFirst(visitor, evaluator)
if err != nil {
logrus.Errorf("unable to get node position: %+v", err)
log.WithFields("error", err).Debug("unable to propagate tree on getAbsPositionNode")
}
return node
@ -355,7 +359,7 @@ func (vm *FileTreeViewModel) ToggleCollapseAll() error {
err := vm.ModelTree.VisitDepthChildFirst(visitor, evaluator)
if err != nil {
logrus.Errorf("unable to propagate tree on ToggleCollapseAll: %+v", err)
log.WithFields("error", err).Debug("unable to propagate tree on ToggleCollapseAll")
}
return nil
@ -370,7 +374,6 @@ func (vm *FileTreeViewModel) ToggleSortOrder() error {
func (vm *FileTreeViewModel) ConstrainLayout() {
if !vm.constrainedRealEstate {
logrus.Debugf("constraining filetree layout")
vm.constrainedRealEstate = true
vm.unconstrainedShowAttributes = vm.ShowAttributes
vm.ShowAttributes = false
@ -379,13 +382,12 @@ func (vm *FileTreeViewModel) ConstrainLayout() {
func (vm *FileTreeViewModel) ExpandLayout() {
if vm.constrainedRealEstate {
logrus.Debugf("expanding filetree layout")
vm.ShowAttributes = vm.unconstrainedShowAttributes
vm.constrainedRealEstate = false
}
}
// ToggleCollapse will collapse/expand the selected FileNode.
// ToggleAttributes will hi
func (vm *FileTreeViewModel) ToggleAttributes() error {
// ignore any attempt to show the attributes when the layout is constrained
if vm.constrainedRealEstate {
@ -424,8 +426,7 @@ func (vm *FileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height in
}, nil)
if err != nil {
logrus.Errorf("unable to propagate vm model tree: %+v", err)
return err
return fmt.Errorf("unable to propagate vm model tree: %w", err)
}
// make a new tree with only visible nodes
@ -441,8 +442,7 @@ func (vm *FileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height in
}, nil)
if err != nil {
logrus.Errorf("unable to propagate vm view tree: %+v", err)
return err
return fmt.Errorf("unable to propagate vm view tree: %w", err)
}
return nil
@ -459,13 +459,11 @@ func (vm *FileTreeViewModel) Render() error {
if idx == vm.bufferIndex {
_, err := fmt.Fprintln(&vm.Buffer, format.Selected(vtclean.Clean(line, false)))
if err != nil {
logrus.Debug("unable to write to buffer: ", err)
return err
}
} else {
_, err := fmt.Fprintln(&vm.Buffer, line)
if err != nil {
logrus.Debug("unable to write to buffer: ", err)
return err
}
}

View file

@ -1,21 +1,30 @@
package viewmodel
import (
"bytes"
"flag"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"go.uber.org/atomic"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/fatih/color"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/dive/image/docker"
"github.com/wagoodman/dive/runtime/ui/format"
)
const allowTestDataCapture = false
var repoRootCache atomic.String
var updateSnapshot = flag.Bool("update", false, "update any test snapshots")
func TestUpdateSnapshotDisabled(t *testing.T) {
require.False(t, *updateSnapshot, "update snapshot flag should be disabled")
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
@ -30,6 +39,7 @@ func testCaseDataFilePath(name string) string {
}
func helperLoadBytes(t *testing.T) []byte {
t.Helper()
path := testCaseDataFilePath(t.Name())
theBytes, err := os.ReadFile(path)
if err != nil {
@ -39,7 +49,9 @@ func helperLoadBytes(t *testing.T) []byte {
}
func helperCaptureBytes(t *testing.T, data []byte) {
if !allowTestDataCapture {
// TODO: switch to https://github.com/gkampitakis/go-snaps
t.Helper()
if *updateSnapshot {
t.Fatalf("cannot capture data in test mode: %s", t.Name())
}
@ -51,54 +63,23 @@ func helperCaptureBytes(t *testing.T, data []byte) {
}
}
func helperCheckDiff(t *testing.T, expected, actual []byte) {
if !bytes.Equal(expected, actual) {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(string(expected), string(actual), true)
t.Errorf("%s", dmp.DiffPrettyText(diffs))
t.Errorf("%s: bytes mismatch", t.Name())
}
}
func assertTestData(t *testing.T, actualBytes []byte) {
path := testCaseDataFilePath(t.Name())
if !fileExists(path) {
if allowTestDataCapture {
helperCaptureBytes(t, actualBytes)
} else {
t.Fatalf("missing test data: %s", path)
}
}
expectedBytes := helperLoadBytes(t)
helperCheckDiff(t, expectedBytes, actualBytes)
}
func initializeTestViewModel(t *testing.T) *FileTreeViewModel {
result := docker.TestAnalysisFromArchive(t, "../../../.data/test-docker-image.tar")
t.Helper()
cache := filetree.NewComparer(result.RefTrees)
errors := cache.BuildCache()
if len(errors) > 0 {
t.Fatalf("%s: unable to build cache: %d errors", t.Name(), len(errors))
}
result := docker.TestAnalysisFromArchive(t, repoPath(t, ".data/test-docker-image.tar"))
require.NotNil(t, result, "unable to load test data")
format.Selected = color.New(color.ReverseVideo, color.Bold).SprintFunc()
vm, err := NewFileTreeViewModel(v1.Config{
Analysis: *result,
Preferences: v1.DefaultPreferences(),
}, 0)
treeStack, failedPaths, err := filetree.StackTreeRange(result.RefTrees, 0, 0)
if len(failedPaths) > 0 {
t.Errorf("expected no filepath errors, got %d", len(failedPaths))
}
if err != nil {
t.Fatalf("%s: unable to stack trees: %v", t.Name(), err)
}
vm, err := NewFileTreeViewModel(treeStack, result.RefTrees, cache)
if err != nil {
t.Fatalf("%s: unable to create tree ViewModel: %+v", t.Name(), err)
}
require.NoError(t, err, "unable to create viewmodel")
return vm
}
func runTestCase(t *testing.T, vm *FileTreeViewModel, width, height int, filterRegex *regexp.Regexp) {
t.Helper()
err := vm.Update(filterRegex, width, height)
if err != nil {
t.Errorf("failed to update viewmodel: %v", err)
@ -109,7 +90,19 @@ func runTestCase(t *testing.T, vm *FileTreeViewModel, width, height int, filterR
t.Errorf("failed to render viewmodel: %v", err)
}
assertTestData(t, vm.Buffer.Bytes())
actualBytes := vm.Buffer.Bytes()
path := testCaseDataFilePath(t.Name())
if !fileExists(path) {
if *updateSnapshot {
helperCaptureBytes(t, actualBytes)
} else {
t.Fatalf("missing test data: %s", path)
}
}
expectedBytes := helperLoadBytes(t)
if d := cmp.Diff(string(expectedBytes), string(actualBytes)); d != "" {
t.Errorf("bytes mismatch (-want +got):\n%s", d)
}
}
func checkError(t *testing.T, err error, message string) {
@ -155,27 +148,36 @@ func TestFileTreeDirCollapse(t *testing.T) {
vm.Setup(0, height)
vm.ShowAttributes = true
assertPath(t, vm, "/bin", "before toggle of bin")
// collapse /bin
err := vm.ToggleCollapse(nil)
checkError(t, err, "unable to collapse /bin")
assertPath(t, vm, "/bin", "after toggle of bin")
moved := vm.CursorDown()
if !moved {
t.Error("unable to cursor down")
}
moved := vm.CursorDown() // select /dev
require.True(t, moved, "unable to cursor down")
assertPath(t, vm, "/dev", "down to dev")
moved = vm.CursorDown()
if !moved {
t.Error("unable to cursor down")
}
moved = vm.CursorDown() // select /etc
require.True(t, moved, "unable to cursor down")
assertPath(t, vm, "/etc", "down to etc")
// collapse /etc
err = vm.ToggleCollapse(nil)
checkError(t, err, "unable to collapse /etc")
assertPath(t, vm, "/etc", "after toggle of etc")
runTestCase(t, vm, width, height, nil)
}
func assertPath(t *testing.T, vm *FileTreeViewModel, expected string, msg string) {
t.Helper()
n := vm.CurrentNode(nil)
require.NotNil(t, n, "unable to get current node")
assert.Equal(t, expected, n.Path(), msg)
}
func TestFileTreeDirCollapseAll(t *testing.T) {
vm := initializeTestViewModel(t)
@ -396,3 +398,25 @@ func TestFileTreeHideTypeWithFilter(t *testing.T) {
runTestCase(t, vm, width, height, regex)
}
func repoPath(t testing.TB, path string) string {
t.Helper()
root := repoRoot(t)
return filepath.Join(root, path)
}
func repoRoot(t testing.TB) string {
val := repoRootCache.Load()
if val != "" {
return val
}
t.Helper()
// use git to find the root of the repo
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
t.Fatalf("failed to get repo root: %v", err)
}
val = strings.TrimSpace(string(out))
repoRootCache.Store(val)
return val
}

View file

@ -0,0 +1,4 @@
rules:
lowestEfficiency: 0.95
highestWastedBytes: 20MB
highestUserWastedPercent: 0.20

View file

@ -0,0 +1,9 @@
rules:
# If the efficiency is measured below X%, mark as failed. (expressed as a percentage between 0-1)
lowestEfficiency: 0.96
# If the amount of wasted space is at least X or larger than X, mark as failed. (expressed in B, KB, MB, and GB)
highestWastedBytes: 19Mb
# If the amount of wasted space makes up for X% of the image, mark as failed. (fail if the threshold is met or crossed; expressed as a percentage between 0-1)
highestUserWastedPercent: 0.6

View file

@ -0,0 +1,10 @@
ci: true
rules:
# lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_LOWEST_EFFICIENCY_THRESHOLD)
lowest-efficiency-threshold: '0.10'
# highest allowable bytes wasted, otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_WASTED_BYTES)
highest-wasted-bytes: '20MB'
# highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_USER_WASTED_PERCENT)
highest-user-wasted-percent: '0.90'

View file

@ -0,0 +1,13 @@
FROM busybox:1.37.0@sha256:37f7b378a29ceb4c551b1b5582e27747b855bbfaa73fa11914fe0df028dc581f
ADD example.md /somefile.txt
RUN mkdir -p /root/example/really/nested
RUN cp /somefile.txt /root/example/somefile1.txt
RUN chmod 444 /root/example/somefile1.txt
RUN cp /somefile.txt /root/example/somefile2.txt
RUN cp /somefile.txt /root/example/somefile3.txt
RUN mv /root/example/somefile3.txt /root/saved.txt
RUN cp /root/saved.txt /root/.saved.txt
RUN chmod +x /root/saved.txt
RUN chmod 421 /root
RUN rm -rf /root/example/
ADD overwrite.md /root/saved.txt

View file

@ -0,0 +1,10 @@
ci: true
rules:
# lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_LOWEST_EFFICIENCY_THRESHOLD)
lowest-efficiency-threshold: '0.10'
# highest allowable bytes wasted, otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_WASTED_BYTES)
highest-wasted-bytes: '20MB'
# highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_USER_WASTED_PERCENT)
highest-user-wasted-percent: '0.90'

View file

@ -0,0 +1,3 @@
# exmaple!
woot!

View file

@ -0,0 +1,3 @@
# evil!
this will overwrite the other file...

View file

@ -0,0 +1,13 @@
FROM busybox:1.37.0@sha256:37f7b378a29ceb4c551b1b5582e27747b855bbfaa73fa11914fe0df028dc581f
ADD example.md /somefile.txt
RUN mkdir -p /root/example/really/nested
RUN cp /somefile.txt /root/example/somefile1.txt
RUN chmod 444 /root/example/somefile1.txt
RUN cp /somefile.txt /root/example/somefile2.txt
RUN cp /somefile.txt /root/example/somefile3.txt
RUN mv /root/example/somefile3.txt /root/saved.txt
RUN cp /root/saved.txt /root/.saved.txt
RUN chmod +x /root/saved.txt
RUN chmod 421 /root
RUN rm -rf /root/example/
ADD overwrite.md /root/saved.txt

View file

@ -0,0 +1,3 @@
ci: true
rules:
lowest-efficiency-threshold: '0.9'

View file

@ -0,0 +1,5 @@
ci: true
rules:
lowest-efficiency-threshold: '0.10'
highest-wasted-bytes: '20MB'
highest-user-wasted-percent: '0.90'

View file

@ -0,0 +1,3 @@
# exmaple!
woot!

View file

@ -0,0 +1,3 @@
# evil!
this will overwrite the other file...

View file

@ -0,0 +1,2 @@
FROM scratch
INVALID woops

Some files were not shown because too many files have changed in this diff Show more