mirror of
https://github.com/wagoodman/dive
synced 2026-03-14 14:25:50 +01:00
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:
parent
bec7cb9625
commit
788fcd3834
156 changed files with 13845 additions and 6991 deletions
|
|
@ -4,7 +4,7 @@
|
|||
/dist
|
||||
!/dist/dive_linux_amd64
|
||||
/ui
|
||||
/utils
|
||||
/internal/utils
|
||||
/image
|
||||
/cmd
|
||||
/build
|
||||
|
|
|
|||
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
38
cmd/build.go
38
cmd/build.go
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
37
cmd/ci.go
37
cmd/ci.go
|
|
@ -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
53
cmd/dive/cli/cli.go
Normal 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
|
||||
}
|
||||
91
cmd/dive/cli/cli_build_test.go
Normal file
91
cmd/dive/cli/cli_build_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
51
cmd/dive/cli/cli_ci_test.go
Normal file
51
cmd/dive/cli/cli_ci_test.go
Normal 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")
|
||||
}
|
||||
17
cmd/dive/cli/cli_config_test.go
Normal file
17
cmd/dive/cli/cli_config_test.go
Normal 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)
|
||||
}
|
||||
29
cmd/dive/cli/cli_json_test.go
Normal file
29
cmd/dive/cli/cli_json_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
105
cmd/dive/cli/cli_load_test.go
Normal file
105
cmd/dive/cli/cli_load_test.go
Normal 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
175
cmd/dive/cli/cli_test.go
Normal 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()
|
||||
}
|
||||
68
cmd/dive/cli/internal/command/adapter/analyzer.go
Normal file
68
cmd/dive/cli/internal/command/adapter/analyzer.go
Normal 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
|
||||
}
|
||||
48
cmd/dive/cli/internal/command/adapter/evaluator.go
Normal file
48
cmd/dive/cli/internal/command/adapter/evaluator.go
Normal 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
|
||||
}
|
||||
63
cmd/dive/cli/internal/command/adapter/exporter.go
Normal file
63
cmd/dive/cli/internal/command/adapter/exporter.go
Normal 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
|
||||
}
|
||||
91
cmd/dive/cli/internal/command/adapter/resolver.go
Normal file
91
cmd/dive/cli/internal/command/adapter/resolver.go
Normal 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
|
||||
}
|
||||
46
cmd/dive/cli/internal/command/build.go
Normal file
46
cmd/dive/cli/internal/command/build.go
Normal 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)
|
||||
}
|
||||
308
cmd/dive/cli/internal/command/ci/evaluator.go
Normal file
308
cmd/dive/cli/internal/command/ci/evaluator.go
Normal 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)
|
||||
}
|
||||
198
cmd/dive/cli/internal/command/ci/evaluator_test.go
Normal file
198
cmd/dive/cli/internal/command/ci/evaluator_test.go
Normal 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
|
||||
}
|
||||
47
cmd/dive/cli/internal/command/ci/rule.go
Normal file
47
cmd/dive/cli/internal/command/ci/rule.go
Normal 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")
|
||||
}
|
||||
}
|
||||
196
cmd/dive/cli/internal/command/ci/rules.go
Normal file
196
cmd/dive/cli/internal/command/ci/rules.go
Normal 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"
|
||||
}
|
||||
86
cmd/dive/cli/internal/command/export/export.go
Normal file
86
cmd/dive/cli/internal/command/export/export.go
Normal 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, "", " ")
|
||||
}
|
||||
19
cmd/dive/cli/internal/command/export/export_test.go
Normal file
19
cmd/dive/cli/internal/command/export/export_test.go
Normal 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)
|
||||
}
|
||||
68
cmd/dive/cli/internal/command/export/main_test.go
Normal file
68
cmd/dive/cli/internal/command/export/main_test.go
Normal 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
|
||||
}
|
||||
4665
cmd/dive/cli/internal/command/export/testdata/snapshots/export_test.snap
vendored
Executable file
4665
cmd/dive/cli/internal/command/export/testdata/snapshots/export_test.snap
vendored
Executable file
File diff suppressed because it is too large
Load diff
99
cmd/dive/cli/internal/command/root.go
Normal file
99
cmd/dive/cli/internal/command/root.go
Normal 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
|
||||
}
|
||||
75
cmd/dive/cli/internal/options/analysis.go
Normal file
75
cmd/dive/cli/internal/options/analysis.go
Normal 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
|
||||
}
|
||||
32
cmd/dive/cli/internal/options/application.go
Normal file
32
cmd/dive/cli/internal/options/application.go
Normal 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,
|
||||
}
|
||||
}
|
||||
105
cmd/dive/cli/internal/options/ci.go
Normal file
105
cmd/dive/cli/internal/options/ci.go
Normal 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
|
||||
}
|
||||
}
|
||||
73
cmd/dive/cli/internal/options/ci_rules.go
Normal file
73
cmd/dive/cli/internal/options/ci_rules.go
Normal 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
|
||||
}
|
||||
40
cmd/dive/cli/internal/options/export.go
Normal file
40
cmd/dive/cli/internal/options/export.go
Normal 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
|
||||
}
|
||||
18
cmd/dive/cli/internal/options/ui.go
Normal file
18
cmd/dive/cli/internal/options/ui.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
39
cmd/dive/cli/internal/options/ui_diff.go
Normal file
39
cmd/dive/cli/internal/options/ui_diff.go
Normal 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
|
||||
}
|
||||
43
cmd/dive/cli/internal/options/ui_filetree.go
Normal file
43
cmd/dive/cli/internal/options/ui_filetree.go
Normal 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
|
||||
}
|
||||
178
cmd/dive/cli/internal/options/ui_keybindings.go
Normal file
178
cmd/dive/cli/internal/options/ui_keybindings.go
Normal 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)")
|
||||
}
|
||||
20
cmd/dive/cli/internal/options/ui_layers.go
Normal file
20
cmd/dive/cli/internal/options/ui_layers.go
Normal 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")
|
||||
}
|
||||
30
cmd/dive/cli/internal/ui/no_ui.go
Normal file
30
cmd/dive/cli/internal/ui/no_ui.go
Normal 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
|
||||
}
|
||||
176
cmd/dive/cli/internal/ui/v1.go
Normal file
176
cmd/dive/cli/internal/ui/v1.go
Normal 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
|
||||
}
|
||||
136
cmd/dive/cli/internal/ui/v1/app/app.go
Normal file
136
cmd/dive/cli/internal/ui/v1/app/app.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package ui
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/awesome-gocui/gocui"
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package ui
|
||||
package app
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
67
cmd/dive/cli/internal/ui/v1/config.go
Normal file
67
cmd/dive/cli/internal/ui/v1/config.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
100
cmd/dive/cli/internal/ui/v1/key/config.go
Normal file
100
cmd/dive/cli/internal/ui/v1/key/config.go
Normal 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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel"
|
||||
)
|
||||
|
||||
type LayerChangeListener func(viewmodel.LayerSelection) error
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
67
cmd/dive/cli/internal/ui/v1/view/views.go
Normal file
67
cmd/dive/cli/internal/ui/v1/view/views.go
Normal 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,
|
||||
}
|
||||
}
|
||||
1
cmd/dive/cli/internal/ui/v1/viewmodel/config.go
Normal file
1
cmd/dive/cli/internal/ui/v1/viewmodel/config.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package viewmodel
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
4
cmd/dive/cli/testdata/config/dive-ci-legacy.yaml
vendored
Normal file
4
cmd/dive/cli/testdata/config/dive-ci-legacy.yaml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
rules:
|
||||
lowestEfficiency: 0.95
|
||||
highestWastedBytes: 20MB
|
||||
highestUserWastedPercent: 0.20
|
||||
9
cmd/dive/cli/testdata/default-ci-config/.dive-ci
vendored
Normal file
9
cmd/dive/cli/testdata/default-ci-config/.dive-ci
vendored
Normal 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
|
||||
10
cmd/dive/cli/testdata/dive-enable-ci.yaml
vendored
Normal file
10
cmd/dive/cli/testdata/dive-enable-ci.yaml
vendored
Normal 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'
|
||||
13
cmd/dive/cli/testdata/image-multi-layer-containerfile/Containerfile
vendored
Normal file
13
cmd/dive/cli/testdata/image-multi-layer-containerfile/Containerfile
vendored
Normal 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
|
||||
10
cmd/dive/cli/testdata/image-multi-layer-containerfile/dive-pass.yaml
vendored
Normal file
10
cmd/dive/cli/testdata/image-multi-layer-containerfile/dive-pass.yaml
vendored
Normal 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'
|
||||
3
cmd/dive/cli/testdata/image-multi-layer-containerfile/example.md
vendored
Normal file
3
cmd/dive/cli/testdata/image-multi-layer-containerfile/example.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# exmaple!
|
||||
|
||||
woot!
|
||||
3
cmd/dive/cli/testdata/image-multi-layer-containerfile/overwrite.md
vendored
Normal file
3
cmd/dive/cli/testdata/image-multi-layer-containerfile/overwrite.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# evil!
|
||||
|
||||
this will overwrite the other file...
|
||||
13
cmd/dive/cli/testdata/image-multi-layer-dockerfile/Dockerfile
vendored
Normal file
13
cmd/dive/cli/testdata/image-multi-layer-dockerfile/Dockerfile
vendored
Normal 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
|
||||
3
cmd/dive/cli/testdata/image-multi-layer-dockerfile/dive-fail.yaml
vendored
Normal file
3
cmd/dive/cli/testdata/image-multi-layer-dockerfile/dive-fail.yaml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ci: true
|
||||
rules:
|
||||
lowest-efficiency-threshold: '0.9'
|
||||
5
cmd/dive/cli/testdata/image-multi-layer-dockerfile/dive-pass.yaml
vendored
Normal file
5
cmd/dive/cli/testdata/image-multi-layer-dockerfile/dive-pass.yaml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ci: true
|
||||
rules:
|
||||
lowest-efficiency-threshold: '0.10'
|
||||
highest-wasted-bytes: '20MB'
|
||||
highest-user-wasted-percent: '0.90'
|
||||
3
cmd/dive/cli/testdata/image-multi-layer-dockerfile/example.md
vendored
Normal file
3
cmd/dive/cli/testdata/image-multi-layer-dockerfile/example.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# exmaple!
|
||||
|
||||
woot!
|
||||
3
cmd/dive/cli/testdata/image-multi-layer-dockerfile/overwrite.md
vendored
Normal file
3
cmd/dive/cli/testdata/image-multi-layer-dockerfile/overwrite.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# evil!
|
||||
|
||||
this will overwrite the other file...
|
||||
2
cmd/dive/cli/testdata/invalid/Dockerfile
vendored
Normal file
2
cmd/dive/cli/testdata/invalid/Dockerfile
vendored
Normal 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
Loading…
Add table
Add a link
Reference in a new issue