diff --git a/cmd/analyze.go b/cmd/analyze.go index 9018784..33b3498 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -8,6 +8,8 @@ import ( "github.com/wagoodman/dive/ui" ) +// analyze takes a docker image tag, digest, or id and displayes the +// image analysis to the screen func analyze(cmd *cobra.Command, args []string) { userImage := args[0] if userImage == "" { diff --git a/cmd/build.go b/cmd/build.go index 7ccdbfc..6c94939 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -1,9 +1,14 @@ package cmd import ( - "fmt" - "github.com/spf13/cobra" + "os/exec" + "os" + "strings" + "io/ioutil" + log "github.com/sirupsen/logrus" + "github.com/wagoodman/dive/image" + "github.com/wagoodman/dive/ui" ) // buildCmd represents the build command @@ -11,21 +16,58 @@ var buildCmd = &cobra.Command{ Use: "build", Short: "Build and analyze a docker image", Long: `Build and analyze a docker image`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("build called") - }, + DisableFlagParsing: true, + Run: doBuild, } func init() { rootCmd.AddCommand(buildCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // buildCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // buildCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } + +// doBuild implements the steps taken for the build command +func doBuild(cmd *cobra.Command, args []string) { + iidfile, err := ioutil.TempFile("/tmp", "dive.*.iid") + if err != nil { + log.Fatal(err) + } + defer os.Remove(iidfile.Name()) + + allArgs := append([]string{"--iidfile", iidfile.Name()}, args...) + err = runDockerCmd("build", allArgs...) + if err != nil { + log.Fatal(err) + } + + imageId, err := ioutil.ReadFile(iidfile.Name()) + if err != nil { + log.Fatal(err) + } + + manifest, refTrees := image.InitializeData(string(imageId)) + ui.Run(manifest, refTrees) +} + +// runDockerCmd runs a given Docker command in the current tty +func runDockerCmd(cmdStr string, args... string) error { + + allArgs := cleanArgs(append([]string{cmdStr}, args...)) + + cmd := exec.Command("docker", allArgs...) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} + +// cleanArgs trims the whitespace from the given set of strings. +func cleanArgs(s []string) []string { + var r []string + for _, str := range s { + if str != "" { + r = append(r, strings.Trim(str, " ")) + } + } + return r +} \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index 5fce13f..5ef24c2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,7 +21,6 @@ var rootCmd = &cobra.Command{ } // Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) @@ -69,6 +68,7 @@ func initConfig() { } } +// initLogging sets up the loggin object with a formatter and location func initLogging() { // TODO: clean this up and make more configurable var filename string = "dive.log" diff --git a/filetree/data.go b/filetree/data.go index 7617842..cc75c06 100644 --- a/filetree/data.go +++ b/filetree/data.go @@ -8,7 +8,6 @@ import ( "io" ) -// enum to show whether a file has changed const ( Unchanged DiffType = iota Changed @@ -16,26 +15,31 @@ const ( Removed ) +// NodeData is the payload for a FileNode type NodeData struct { ViewInfo ViewInfo FileInfo FileInfo DiffType DiffType } +// ViewInfo contains UI specific detail for a specific FileNode type ViewInfo struct { Collapsed bool Hidden bool } +// FileInfo contains tar metadata for a specific FileNode type FileInfo struct { Path string - Typeflag byte + TypeFlag byte MD5sum [16]byte TarHeader tar.Header } +// DiffType defines the comparison result between two FileNodes type DiffType int +// NewNodeData creates an empty NodeData struct for a FileNode func NewNodeData() (*NodeData) { return &NodeData{ ViewInfo: *NewViewInfo(), @@ -44,6 +48,7 @@ func NewNodeData() (*NodeData) { } } +// Copy duplicates a NodeData func (data *NodeData) Copy() (*NodeData) { return &NodeData{ ViewInfo: *data.ViewInfo.Copy(), @@ -53,6 +58,7 @@ func (data *NodeData) Copy() (*NodeData) { } +// NewViewInfo creates a default ViewInfo func NewViewInfo() (view *ViewInfo) { return &ViewInfo{ Collapsed: false, @@ -60,18 +66,20 @@ func NewViewInfo() (view *ViewInfo) { } } +// Copy duplicates a ViewInfo func (view *ViewInfo) Copy() (newView *ViewInfo) { newView = NewViewInfo() *newView = *view return newView } +// NewFileInfo extracts the metadata from a tar header and file contents and generates a new FileInfo object. func NewFileInfo(reader *tar.Reader, header *tar.Header, path string) FileInfo { if header.Typeflag == tar.TypeDir { return FileInfo{ - Path: path, - Typeflag: header.Typeflag, - MD5sum: [16]byte{}, + Path: path, + TypeFlag: header.Typeflag, + MD5sum: [16]byte{}, TarHeader: *header, } } @@ -83,14 +91,39 @@ func NewFileInfo(reader *tar.Reader, header *tar.Header, path string) FileInfo { return FileInfo{ Path: path, - Typeflag: header.Typeflag, + TypeFlag: header.Typeflag, MD5sum: md5.Sum(fileBytes), TarHeader: *header, } } -func (d DiffType) String() string { - switch d { +// Copy duplicates a FileInfo +func (data *FileInfo) Copy() *FileInfo { + if data == nil { + return nil + } + return &FileInfo{ + Path: data.Path, + TypeFlag: data.TypeFlag, + MD5sum: data.MD5sum, + TarHeader: data.TarHeader, + } +} + +// Compare determines the DiffType between two FileInfos based on the type and contents of each given FileInfo +func (data *FileInfo) Compare(other FileInfo) DiffType { + if data.TypeFlag == other.TypeFlag { + if bytes.Compare(data.MD5sum[:], other.MD5sum[:]) == 0 { + return Unchanged + } + } + return Changed +} + + +// String of a DiffType +func (diff DiffType) String() string { + switch diff { case Unchanged: return "Unchanged" case Changed: @@ -100,34 +133,17 @@ func (d DiffType) String() string { case Removed: return "Removed" default: - return fmt.Sprintf("%d", int(d)) + return fmt.Sprintf("%d", int(diff)) } } -func (a DiffType) merge(b DiffType) DiffType { - if a == b { - return a +// merge two DiffTypes into a single result. Essentially, return the given value unless they two values differ, +// in which case we can only determine that there is "a change". +func (diff DiffType) merge(other DiffType) DiffType { + if diff == other { + return diff } return Changed } -func (data *FileInfo) Copy() *FileInfo { - if data == nil { - return nil - } - return &FileInfo{ - Path: data.Path, - Typeflag: data.Typeflag, - MD5sum: data.MD5sum, - TarHeader: data.TarHeader, - } -} -func (data *FileInfo) getDiffType(other FileInfo) DiffType { - if data.Typeflag == other.Typeflag { - if bytes.Compare(data.MD5sum[:], other.MD5sum[:]) == 0 { - return Unchanged - } - } - return Changed -} diff --git a/filetree/data_test.go b/filetree/data_test.go index f107e30..7881ce7 100644 --- a/filetree/data_test.go +++ b/filetree/data_test.go @@ -34,7 +34,7 @@ func TestMergeDiffTypes(t *testing.T) { func BlankFileChangeInfo(path string) (f *FileInfo) { result := FileInfo{ Path: path, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0}, } return &result diff --git a/filetree/efficiency.go b/filetree/efficiency.go new file mode 100644 index 0000000..403cb41 --- /dev/null +++ b/filetree/efficiency.go @@ -0,0 +1,85 @@ +package filetree + +import ( + "sort" +) + +// EfficiencyData represents the storage and reference statistics for a given file tree path. +type EfficiencyData struct { + Path string + Nodes []*FileNode + CumulativeSize int64 + minDiscoveredSize int64 +} + +// EfficiencySlice represents an ordered set of EfficiencyData data structures. +type EfficiencySlice []*EfficiencyData + +// Len is required for sorting. +func (efs EfficiencySlice) Len() int { + return len(efs) +} + +// Swap operation is required for sorting. +func (efs EfficiencySlice) Swap(i, j int) { + efs[i], efs[j] = efs[j], efs[i] +} + +// Less comparison is required for sorting. +func (efs EfficiencySlice) Less(i, j int) bool { + return efs[i].CumulativeSize < efs[j].CumulativeSize +} + +// Efficiency returns the score and file set of the given set of FileTrees (layers). This is loosely based on: +// 1. Files that are duplicated across layers discounts your score, weighted by file size +// 2. Files that are removed discounts your score, weighted by the original file size +func Efficiency(trees []*FileTree) (float64, EfficiencySlice) { + efficiencyMap := make(map[string]*EfficiencyData) + inefficientMatches := make(EfficiencySlice, 0) + + visitor := func(node *FileNode) error { + path := node.Path() + if _, ok := efficiencyMap[path]; !ok { + efficiencyMap[path] = &EfficiencyData{ + Path: path, + Nodes: make([]*FileNode,0), + minDiscoveredSize: -1, + } + } + data := efficiencyMap[path] + data.CumulativeSize += node.Data.FileInfo.TarHeader.Size + if data.minDiscoveredSize < 0 || node.Data.FileInfo.TarHeader.Size < data.minDiscoveredSize { + data.minDiscoveredSize = node.Data.FileInfo.TarHeader.Size + } + data.Nodes = append(data.Nodes, node) + + if len(data.Nodes) == 2 { + inefficientMatches = append(inefficientMatches, data) + } + + return nil + } + visitEvaluator := func(node *FileNode) bool { + return node.IsLeaf() + } + for _, tree := range trees { + tree.VisitDepthChildFirst(visitor, visitEvaluator) + } + + + // calculate the score + var minimumPathSizes int64 + var discoveredPathSizes int64 + + for _, value := range efficiencyMap { + minimumPathSizes += value.minDiscoveredSize + discoveredPathSizes += value.CumulativeSize + } + score := float64(minimumPathSizes) / float64(discoveredPathSizes) + + sort.Sort(inefficientMatches) + + return score, inefficientMatches +} + + diff --git a/filetree/node.go b/filetree/node.go index b000f2e..f876e8e 100644 --- a/filetree/node.go +++ b/filetree/node.go @@ -22,6 +22,7 @@ var diffTypeColor = map[DiffType]*color.Color { Unchanged: color.New(color.Reset), } +// FileNode represents a single file, its relation to files beneath it, the tree it exists in, and the metadata of the given file. type FileNode struct { Tree *FileTree Parent *FileNode @@ -31,6 +32,7 @@ type FileNode struct { path string } +// NewNode creates a new FileNode relative to the given parent node with a payload. func NewNode(parent *FileNode, name string, data FileInfo) (node *FileNode) { node = new(FileNode) node.Name = name @@ -45,6 +47,7 @@ func NewNode(parent *FileNode, name string, data FileInfo) (node *FileNode) { return node } +// renderTreeLine returns a string representing this FileNode in the context of a greater ASCII tree. func (node *FileNode) renderTreeLine(spaces []bool, last bool, collapsed bool) string { var otherBranches string for _, space := range spaces { @@ -68,6 +71,7 @@ func (node *FileNode) renderTreeLine(spaces []bool, last bool, collapsed bool) s return otherBranches + thisBranch + collapsedIndicator + node.String() + newLine } +// Copy duplicates the existing node relative to a new parent node. func (node *FileNode) Copy(parent *FileNode) *FileNode { newNode := NewNode(parent, node.Name, node.Data.FileInfo) newNode.Data.ViewInfo = node.Data.ViewInfo @@ -79,6 +83,7 @@ func (node *FileNode) Copy(parent *FileNode) *FileNode { return newNode } +// AddChild creates a new node relative to the current FileNode. func (node *FileNode) AddChild(name string, data FileInfo) (child *FileNode) { child = NewNode(node, name, data) if node.Children[name] != nil { @@ -91,6 +96,7 @@ func (node *FileNode) AddChild(name string, data FileInfo) (child *FileNode) { return child } +// Remove deletes the current FileNode from it's parent FileNode's relations. func (node *FileNode) Remove() error { if node == node.Tree.Root { return fmt.Errorf("cannot remove the tree root") @@ -103,6 +109,7 @@ func (node *FileNode) Remove() error { return nil } +// String shows the filename formatted into the proper color (by DiffType), additionally indicating if it is a symlink. func (node *FileNode) String() string { var display string if node == nil { @@ -116,6 +123,7 @@ func (node *FileNode) String() string { return diffTypeColor[node.Data.DiffType].Sprint(display) } +// MetadatString returns the FileNode metadata in a columnar string. func (node *FileNode) MetadataString() string { if node == nil { return "" @@ -130,7 +138,6 @@ func (node *FileNode) MetadataString() string { group := node.Data.FileInfo.TarHeader.Gid userGroup := fmt.Sprintf("%d:%d", user, group) - //size := humanize.Bytes(uint64(node.Data.FileInfo.TarHeader.FileInfo().Size())) var sizeBytes int64 if node.Data.FileInfo.TarHeader.FileInfo().IsDir() { @@ -152,7 +159,8 @@ func (node *FileNode) MetadataString() string { return diffTypeColor[node.Data.DiffType].Sprint(fmt.Sprintf(AttributeFormat, dir, fileMode, userGroup, size)) } -func (node *FileNode) VisitDepthChildFirst(visiter Visiter, evaluator VisitEvaluator) error { +// VisitDepthChildFirst iterates a tree depth-first (starting at this FileNode), evaluating the deepest depths first (visit on bubble up) +func (node *FileNode) VisitDepthChildFirst(visitor Visitor, evaluator VisitEvaluator) error { var keys []string for key := range node.Children { keys = append(keys, key) @@ -160,7 +168,7 @@ func (node *FileNode) VisitDepthChildFirst(visiter Visiter, evaluator VisitEvalu sort.Strings(keys) for _, name := range keys { child := node.Children[name] - err := child.VisitDepthChildFirst(visiter, evaluator) + err := child.VisitDepthChildFirst(visitor, evaluator) if err != nil { return err } @@ -169,13 +177,14 @@ func (node *FileNode) VisitDepthChildFirst(visiter Visiter, evaluator VisitEvalu if node == node.Tree.Root { return nil } else if evaluator != nil && evaluator(node) || evaluator == nil { - return visiter(node) + return visitor(node) } return nil } -func (node *FileNode) VisitDepthParentFirst(visiter Visiter, evaluator VisitEvaluator) error { +// VisitDepthParentFirst iterates a tree depth-first (starting at this FileNode), evaluating the shallowest depths first (visit while sinking down) +func (node *FileNode) VisitDepthParentFirst(visitor Visitor, evaluator VisitEvaluator) error { var err error doVisit := evaluator != nil && evaluator(node) || evaluator == nil @@ -186,7 +195,7 @@ func (node *FileNode) VisitDepthParentFirst(visiter Visiter, evaluator VisitEval // never visit the root node if node != node.Tree.Root { - err = visiter(node) + err = visitor(node) if err != nil { return err } @@ -199,7 +208,7 @@ func (node *FileNode) VisitDepthParentFirst(visiter Visiter, evaluator VisitEval sort.Strings(keys) for _, name := range keys { child := node.Children[name] - err = child.VisitDepthParentFirst(visiter, evaluator) + err = child.VisitDepthParentFirst(visitor, evaluator) if err != nil { return err } @@ -207,10 +216,17 @@ func (node *FileNode) VisitDepthParentFirst(visiter Visiter, evaluator VisitEval return err } +// IsWhiteout returns an indication if this file may be a overlay-whiteout file. func (node *FileNode) IsWhiteout() bool { return strings.HasPrefix(node.Name, whiteoutPrefix) } +// IsLeaf returns true is the current node has no child nodes. +func (node *FileNode) IsLeaf() bool { + return len(node.Children) == 0 +} + +// Path returns a slash-delimited string from the root of the greater tree to the current node (e.g. /a/path/to/here) func (node *FileNode) Path() string { if node.path == "" { path := []string{} @@ -234,14 +250,9 @@ func (node *FileNode) Path() string { return node.path } -func (node *FileNode) IsLeaf() bool { - return len(node.Children) == 0 -} - +// deriveDiffType determines a DiffType to the current FileNode. Note: the DiffType of a node is always the DiffType of +// its attributes and its contents. The contents are the bytes of the file of the children of a directory. func (node *FileNode) deriveDiffType(diffType DiffType) error { - // THE DIFF_TYPE OF A NODE IS ALWAYS THE DIFF_TYPE OF ITS ATTRIBUTES AND ITS CONTENTS - // THE CONTENTS ARE THE BYTES OF A FILE OR THE CHILDREN OF A DIRECTORY - if node.IsLeaf() { return node.AssignDiffType(diffType) } @@ -255,6 +266,7 @@ func (node *FileNode) deriveDiffType(diffType DiffType) error { return node.AssignDiffType(myDiffType) } +// AssignDiffType will assign the given DiffType to this node, possible affecting child nodes. func (node *FileNode) AssignDiffType(diffType DiffType) error { var err error @@ -277,27 +289,27 @@ func (node *FileNode) AssignDiffType(diffType DiffType) error { return nil } -func (a *FileNode) compare(b *FileNode) DiffType { - if a == nil && b == nil { +// compare the current node against the given node, returning a definitive DiffType. +func (node *FileNode) compare(other *FileNode) DiffType { + if node == nil && other == nil { return Unchanged } - // a is nil but not b - if a == nil && b != nil { + + if node == nil && other != nil { return Added } - // b is nil but not a - if a != nil && b == nil { + if node != nil && other == nil { return Removed } - if b.IsWhiteout() { + if other.IsWhiteout() { return Removed } - if a.Name != b.Name { + if node.Name != other.Name { panic("comparing mismatched nodes") } // TODO: fails on nil - return a.Data.FileInfo.getDiffType(b.Data.FileInfo) + return node.Data.FileInfo.Compare(other.Data.FileInfo) } diff --git a/filetree/tree.go b/filetree/tree.go index a7bfddb..4f806a1 100644 --- a/filetree/tree.go +++ b/filetree/tree.go @@ -18,6 +18,7 @@ const ( collapsedItem = "⊕ " ) +// FileTree represents a set of files, directories, and their relations. type FileTree struct { Root *FileNode Size int @@ -26,6 +27,7 @@ type FileTree struct { Id uuid.UUID } +// NewFileTree creates an empty FileTree func NewFileTree() (tree *FileTree) { tree = new(FileTree) tree.Size = 0 @@ -36,6 +38,8 @@ func NewFileTree() (tree *FileTree) { return tree } +// renderParams is a representation of a FileNode in the context of the greater tree. All +// data stored is necessary for rendering a single line in a tree format. type renderParams struct{ node *FileNode spaces []bool @@ -44,13 +48,15 @@ type renderParams struct{ isLast bool } +// renderStringTreeBetween returns a string representing the given tree between the given rows. Since each node +// is rendered on its own line, the returned string shows the visible nodes not affected by a collapsed parent. func (tree *FileTree) renderStringTreeBetween(startRow, stopRow int, showAttributes bool) string { // generate a list of nodes to render - var params []renderParams = make([]renderParams,0) + var params = make([]renderParams,0) var result string // visit from the front of the list - var paramsToVisit = []renderParams{ renderParams{node: tree.Root, spaces: []bool{}, showCollapsed: false, isLast: false} } + var paramsToVisit = []renderParams{ {node: tree.Root, spaces: []bool{}, showCollapsed: false, isLast: false} } for currentRow := 0; len(paramsToVisit) > 0 && currentRow <= stopRow; currentRow++ { // pop the first node var currentParams renderParams @@ -61,6 +67,7 @@ func (tree *FileTree) renderStringTreeBetween(startRow, stopRow int, showAttribu for key := range currentParams.node.Children { keys = append(keys, key) } + // we should always visit nodes in order sort.Strings(keys) var childParams = make([]renderParams,0) @@ -119,14 +126,17 @@ func (tree *FileTree) renderStringTreeBetween(startRow, stopRow int, showAttribu return result } +// String returns the entire tree in an ASCII representation. func (tree *FileTree) String(showAttributes bool) string { return tree.renderStringTreeBetween(0, tree.Size, showAttributes) } +// StringBetween returns a partial tree in an ASCII representation. func (tree *FileTree) StringBetween(start, stop uint, showAttributes bool) string { return tree.renderStringTreeBetween(int(start), int(stop), showAttributes) } +// Copy returns a copy of the given FileTree func (tree *FileTree) Copy() *FileTree { newTree := NewFileTree() newTree.Size = tree.Size @@ -142,19 +152,23 @@ func (tree *FileTree) Copy() *FileTree { return newTree } -type Visiter func(*FileNode) error +// Visitor is a function that processes, observes, or otherwise transforms the given node +type Visitor func(*FileNode) error + +// VisitEvaluator is a function that indicates whether the given node should be visited by a Visitor. type VisitEvaluator func(*FileNode) bool -// DFS bubble up -func (tree *FileTree) VisitDepthChildFirst(visiter Visiter, evaluator VisitEvaluator) error { - return tree.Root.VisitDepthChildFirst(visiter, evaluator) +// VisitDepthChildFirst iterates the given tree depth-first, evaluating the deepest depths first (visit on bubble up) +func (tree *FileTree) VisitDepthChildFirst(visitor Visitor, evaluator VisitEvaluator) error { + return tree.Root.VisitDepthChildFirst(visitor, evaluator) } -// DFS sink down -func (tree *FileTree) VisitDepthParentFirst(visiter Visiter, evaluator VisitEvaluator) error { - return tree.Root.VisitDepthParentFirst(visiter, evaluator) +// VisitDepthParentFirst iterates the given tree depth-first, evaluating the shallowest depths first (visit while sinking down) +func (tree *FileTree) VisitDepthParentFirst(visitor Visitor, evaluator VisitEvaluator) error { + return tree.Root.VisitDepthParentFirst(visitor, evaluator) } +// Stack takes two trees and combines them together. This is done by "stacking" the given tree on top of the owning tree. func (tree *FileTree) Stack(upper *FileTree) error { graft := func(node *FileNode) error { if node.IsWhiteout() { @@ -173,6 +187,7 @@ func (tree *FileTree) Stack(upper *FileTree) error { return upper.VisitDepthChildFirst(graft, nil) } +// GetNode fetches a single node when given a slash-delimited string from root ('/') to the desired node (e.g. '/a/node/path') func (tree *FileTree) GetNode(path string) (*FileNode, error) { nodeNames := strings.Split(strings.Trim(path, "/"), "/") node := tree.Root @@ -188,6 +203,7 @@ func (tree *FileTree) GetNode(path string) (*FileNode, error) { return node, nil } +// AddPath adds a new node to the tree with the given payload func (tree *FileTree) AddPath(path string, data FileInfo) (*FileNode, error) { nodeNames := strings.Split(strings.Trim(path, "/"), "/") node := tree.Root @@ -213,6 +229,7 @@ func (tree *FileTree) AddPath(path string, data FileInfo) (*FileNode, error) { return node, nil } +// RemovePath removes a node from the tree given its path. func (tree *FileTree) RemovePath(path string) error { node, err := tree.GetNode(path) if err != nil { @@ -221,10 +238,11 @@ func (tree *FileTree) RemovePath(path string) error { return node.Remove() } +// Compare marks the FileNodes in the owning tree with DiffType annotations when compared to the given tree. func (tree *FileTree) Compare(upper *FileTree) error { graft := func(upperNode *FileNode) error { if upperNode.IsWhiteout() { - err := tree.MarkRemoved(upperNode.Path()) + err := tree.markRemoved(upperNode.Path()) if err != nil { return fmt.Errorf("cannot remove upperNode %s: %v", upperNode.Path(), err.Error()) } @@ -246,7 +264,8 @@ func (tree *FileTree) Compare(upper *FileTree) error { return upper.VisitDepthChildFirst(graft, nil) } -func (tree *FileTree) MarkRemoved(path string) error { +// markRemoved annotates the FileNode at the given path as Removed. +func (tree *FileTree) markRemoved(path string) error { node, err := tree.GetNode(path) if err != nil { return err @@ -254,6 +273,7 @@ func (tree *FileTree) MarkRemoved(path string) error { return node.AssignDiffType(Removed) } +// StackRange combines an array of trees into a single tree func StackRange(trees []*FileTree, start, stop int) *FileTree { tree := trees[0].Copy() for idx := start; idx <= stop; idx++ { @@ -262,30 +282,3 @@ func StackRange(trees []*FileTree, start, stop int) *FileTree { return tree } - -// EfficiencyMap creates a map[string]int showing how often each int -// appears in the -func EfficiencyMap(trees []*FileTree) map[string]int { - result := make(map[string]int) - visitor := func(node *FileNode) error { - result[node.Path()]++ - return nil - } - visitEvaluator := func(node *FileNode) bool { - return node.IsLeaf() - } - for _, tree := range trees { - tree.VisitDepthChildFirst(visitor, visitEvaluator) - } - return result -} - -func EfficiencyScore(trees []*FileTree) float64 { - efficiencyMap := EfficiencyMap(trees) - uniquePaths := len(efficiencyMap) - pathAppearances := 0 - for _, value := range efficiencyMap { - pathAppearances += value - } - return float64(uniquePaths) / float64(pathAppearances) -} diff --git a/filetree/tree_test.go b/filetree/tree_test.go index 2711ede..6c95df0 100644 --- a/filetree/tree_test.go +++ b/filetree/tree_test.go @@ -262,7 +262,7 @@ func TestCompareWithNoChanges(t *testing.T) { for _, value := range paths { fakeData := FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, } lowerTree.AddPath(value, fakeData) @@ -293,7 +293,7 @@ func TestCompareWithAdds(t *testing.T) { for _, value := range lowerPaths { lowerTree.AddPath(value, FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, }) } @@ -301,7 +301,7 @@ func TestCompareWithAdds(t *testing.T) { for _, value := range upperPaths { upperTree.AddPath(value, FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, }) } @@ -353,12 +353,12 @@ func TestCompareWithChanges(t *testing.T) { for _, value := range paths { lowerTree.AddPath(value, FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, }) upperTree.AddPath(value, FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0}, }) } @@ -403,7 +403,7 @@ func TestCompareWithRemoves(t *testing.T) { for _, value := range lowerPaths { fakeData := FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, } lowerTree.AddPath(value, fakeData) @@ -412,7 +412,7 @@ func TestCompareWithRemoves(t *testing.T) { for _, value := range upperPaths { fakeData := FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, } upperTree.AddPath(value, fakeData) @@ -473,7 +473,7 @@ func TestStackRange(t *testing.T) { for _, value := range lowerPaths { fakeData := FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, } lowerTree.AddPath(value, fakeData) @@ -482,7 +482,7 @@ func TestStackRange(t *testing.T) { for _, value := range upperPaths { fakeData := FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0}, } upperTree.AddPath(value, fakeData) @@ -499,7 +499,7 @@ func TestRemoveOnIterate(t *testing.T) { for _, value := range paths { fakeData := FileInfo{ Path: value, - Typeflag: 1, + TypeFlag: 1, MD5sum: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, } node, err := tree.AddPath(value, fakeData) @@ -554,7 +554,7 @@ func TestEfficiencyScore(t *testing.T) { trees[ix] = tree } expected := 2.0 / 6.0 - actual := EfficiencyScore(trees) + actual := CalculateEfficiency(trees) if math.Abs(expected-actual) > 0.0001 { t.Fatalf("Expected %f but got %f", expected, actual) } @@ -567,7 +567,7 @@ func TestEfficiencyScore(t *testing.T) { trees[ix] = tree } expected = 1.0 - actual = EfficiencyScore(trees) + actual = CalculateEfficiency(trees) if math.Abs(expected-actual) > 0.0001 { t.Fatalf("Expected %f but got %f", expected, actual) } diff --git a/image/image.go b/image/image.go index 78be4f0..ef09f71 100644 --- a/image/image.go +++ b/image/image.go @@ -17,10 +17,6 @@ import ( "golang.org/x/net/context" ) -const ( - LayerFormat = "%-25s %5s %7s %s" -) - func check(e error) { if e != nil { panic(e) @@ -147,13 +143,13 @@ func InitializeData(imageID string) ([]*Layer, []*filetree.FileTree) { // save this image to disk temporarily to get the content info fmt.Println("Fetching image...") imageTarPath, tmpDir := saveImage(imageID) - // imageTarPath := "/tmp/dive932744808/image.tar" - // tmpDir := "/tmp/dive031537738" + // imageTarPath := "/tmp/dive229500681/image.tar" + // tmpDir := "/tmp/dive229500681" // fmt.Println(tmpDir) defer os.RemoveAll(tmpDir) // read through the image contents and build a tree - fmt.Println("Reading image...") + fmt.Printf("Reading image '%s'...\n", imageID) tarFile, err := os.Open(imageTarPath) if err != nil { fmt.Println(err) diff --git a/image/layer.go b/image/layer.go index 85d7ae2..36cbf51 100644 --- a/image/layer.go +++ b/image/layer.go @@ -4,10 +4,14 @@ import ( "github.com/wagoodman/dive/filetree" "strings" "fmt" - "strconv" "github.com/dustin/go-humanize" ) +const ( + LayerFormat = "%-25s %7s %s" +) + +// Layer represents a Docker image layer and metadata type Layer struct { TarPath string History ImageHistoryEntry @@ -16,6 +20,7 @@ type Layer struct { RefTrees []*filetree.FileTree } +// Id returns the truncated id of the current layer. func (layer *Layer) Id() string { rangeBound := 25 if length := len(layer.History.ID); length < 25 { @@ -31,12 +36,11 @@ func (layer *Layer) Id() string { return id } +// String represents a layer in a columnar format. func (layer *Layer) String() string { return fmt.Sprintf(LayerFormat, layer.Id(), - strconv.Itoa(int(100.0*filetree.EfficiencyScore(layer.RefTrees[:layer.Index+1]))) + "%", - //"100%", humanize.Bytes(uint64(layer.History.Size)), strings.TrimPrefix(layer.History.CreatedBy, "/bin/sh -c ")) } diff --git a/ui/detailsview.go b/ui/detailsview.go new file mode 100644 index 0000000..11a4cac --- /dev/null +++ b/ui/detailsview.go @@ -0,0 +1,138 @@ +package ui + +import ( + "fmt" + + "github.com/jroimartin/gocui" + "github.com/lunixbochs/vtclean" + "strings" + "github.com/wagoodman/dive/filetree" + "strconv" + "github.com/dustin/go-humanize" +) + +// DetailsView holds the UI objects and data models for populating the lower-left pane. Specifically the pane that +// shows the layer details and image statistics. +type DetailsView struct { + Name string + gui *gocui.Gui + view *gocui.View + header *gocui.View + efficiency float64 + inefficiencies filetree.EfficiencySlice +} + +// NewDetailsView creates a new view object attached the the global [gocui] screen object. +func NewDetailsView(name string, gui *gocui.Gui) (detailsView *DetailsView) { + detailsView = new(DetailsView) + + // populate main fields + detailsView.Name = name + detailsView.gui = gui + + return detailsView +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (view *DetailsView) Setup(v *gocui.View, header *gocui.View) error { + + // set view options + view.view = v + view.view.Editable = false + view.view.Wrap = true + view.view.Highlight = false + view.view.Frame = false + + view.header = header + view.header.Editable = false + view.header.Wrap = false + view.header.Frame = false + + // set keybindings + if err := view.gui.SetKeybinding(view.Name, gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return view.CursorDown() }); err != nil { + return err + } + if err := view.gui.SetKeybinding(view.Name, gocui.KeyArrowUp, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return view.CursorUp() }); err != nil { + return err + } + + return view.Render() +} + +// IsVisible indicates if the details view pane is currently initialized. +func (view *DetailsView) IsVisible() bool { + if view == nil {return false} + return true +} + +// CursorDown moves the cursor down in the details pane (currently indicates nothing). +func (view *DetailsView) CursorDown() error { + return CursorDown(view.gui, view.view) +} + +// CursorUp moves the cursor up in the details pane (currently indicates nothing). +func (view *DetailsView) CursorUp() error { + return CursorUp(view.gui, view.view) +} + +// Update refreshes the state objects for future rendering. Note: we only need to update this view upon the initial tree load +func (view *DetailsView) Update() error { + layerTrees := Views.Tree.RefTrees + view.efficiency, view.inefficiencies = filetree.Efficiency(layerTrees[:len(layerTrees)-1]) + return nil +} + +// Render flushes the state objects to the screen. The details pane reports: +// 1. the current selected layer's command string +// 2. the image efficiency score +// 3. the estimated wasted image space +// 4. a list of inefficient file allocations +func (view *DetailsView) Render() error { + currentLayer := Views.Layer.currentLayer() + + var wastedSpace int64 + + template := "%5s %12s %-s\n" + var trueInefficiencies int + inefficiencyReport := fmt.Sprintf(Formatting.Header(template), "Count", "Total Space", "Path") + for idx := len(view.inefficiencies)-1; idx > 0; idx-- { + data := view.inefficiencies[idx] + if data.CumulativeSize == 0 { + continue + } + trueInefficiencies++ + wastedSpace += data.CumulativeSize + inefficiencyReport += fmt.Sprintf(template, strconv.Itoa(len(data.Nodes)), humanize.Bytes(uint64(data.CumulativeSize)), data.Path) + } + if trueInefficiencies == 0 { + inefficiencyReport = "" + } + + effStr := fmt.Sprintf("\n%s %d %%", Formatting.Header("Image efficiency score:"), int(100.0*view.efficiency)) + spaceStr := fmt.Sprintf("%s %s\n", Formatting.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace))) + + view.gui.Update(func(g *gocui.Gui) error { + // update header + view.header.Clear() + width, _ := g.Size() + headerStr := fmt.Sprintf("[Image & Layer Details]%s", strings.Repeat("─",width*2)) + fmt.Fprintln(view.header, Formatting.Header(vtclean.Clean(headerStr, false))) + + // update contents + view.view.Clear() + fmt.Fprintln(view.view, Formatting.Header("Layer Command")) + fmt.Fprintln(view.view, currentLayer.History.CreatedBy) + + fmt.Fprintln(view.view, effStr) + fmt.Fprintln(view.view, spaceStr) + + fmt.Fprintln(view.view, inefficiencyReport) + return nil + }) + return nil +} + +// KeyHelp indicates all the possible actions a user can take while the current pane is selected (currently does nothing). +func (view *DetailsView) KeyHelp() string { + return "TBD" +} diff --git a/ui/filetreeview.go b/ui/filetreeview.go index cec927c..dee4255 100644 --- a/ui/filetreeview.go +++ b/ui/filetreeview.go @@ -17,7 +17,8 @@ const ( type CompareType int - +// FileTreeView 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 FileTreeView struct { Name string gui *gocui.Gui @@ -33,19 +34,21 @@ type FileTreeView struct { bufferIndexLowerBound uint } -func NewFileTreeView(name string, gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree) (treeview *FileTreeView) { - treeview = new(FileTreeView) +// NewFileTreeView creates a new view object attached the the global [gocui] screen object. +func NewFileTreeView(name string, gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree) (treeView *FileTreeView) { + treeView = new(FileTreeView) // populate main fields - treeview.Name = name - treeview.gui = gui - treeview.ModelTree = tree - treeview.RefTrees = refTrees - treeview.HiddenDiffTypes = make([]bool, 4) + treeView.Name = name + treeView.gui = gui + treeView.ModelTree = tree + treeView.RefTrees = refTrees + treeView.HiddenDiffTypes = make([]bool, 4) - return treeview + return treeView } +// Setup initializes the UI concerns within the context of a global [gocui] view object. func (view *FileTreeView) Setup(v *gocui.View, header *gocui.View) error { // set view options @@ -91,20 +94,31 @@ func (view *FileTreeView) Setup(v *gocui.View, header *gocui.View) error { return nil } +// height obtains the height of the current pane (taking into account the lost space due to headers and footers). func (view *FileTreeView) height() uint { _, height := view.view.Size() return uint(height - 2) } +// IsVisible indicates if the file tree view pane is currently initialized func (view *FileTreeView) IsVisible() bool { if view == nil {return false} return true } +// resetCursor moves the cursor back to the top of the buffer and translates to the top of the buffer. +func (view *FileTreeView) resetCursor() { + view.view.SetCursor(0, 0) + view.TreeIndex = 0 + view.bufferIndex = 0 + view.bufferIndexLowerBound = 0 + view.bufferIndexUpperBound = view.height() +} +// setTreeByLayer populates the view model by stacking the indicated image layer file trees. func (view *FileTreeView) setTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { if topTreeStop > len(view.RefTrees)-1 { - return fmt.Errorf("Invalid layer index given: %d of %d", topTreeStop, len(view.RefTrees)-1) + return fmt.Errorf("invalid layer index given: %d of %d", topTreeStop, len(view.RefTrees)-1) } newTree := filetree.StackRange(view.RefTrees, bottomTreeStart, bottomTreeStop) @@ -122,13 +136,14 @@ func (view *FileTreeView) setTreeByLayer(bottomTreeStart, bottomTreeStop, topTre } view.ModelTree.VisitDepthChildFirst(visitor, nil) - view.view.SetCursor(0, 0) - view.TreeIndex = 0 + view.resetCursor() + view.ModelTree = newTree view.Update() return view.Render() } +// doCursorUp performs the internal view's buffer adjustments on cursor up. Note: this is independent of the gocui buffer. func (view *FileTreeView) doCursorUp() { view.TreeIndex-- if view.TreeIndex < view.bufferIndexLowerBound { @@ -141,6 +156,7 @@ func (view *FileTreeView) doCursorUp() { } } +// doCursorDown performs the internal view's buffer adjustments on cursor down. Note: this is independent of the gocui buffer. func (view *FileTreeView) doCursorDown() { view.TreeIndex++ if view.TreeIndex > view.bufferIndexUpperBound { @@ -153,22 +169,21 @@ func (view *FileTreeView) doCursorDown() { } } +// CursorDown moves the cursor down and renders the view. +// Note: we cannot use the gocui buffer since any state change requires writing the entire tree to the buffer. +// Instead we are keeping an upper and lower bounds of the tree string to render and only flushing +// this range into the view buffer. This is much faster when tree sizes are large. func (view *FileTreeView) CursorDown() error { - // we cannot use the gocui buffer since any state change requires writing the entire tree to the buffer. - // Instead we are keeping an upper and lower bounds of the tree string to render and only flushing - // this range into the view buffer. This is much faster when tree sizes are large. - view.doCursorDown() return view.Render() } - +// CursorUp moves the cursor up and renders the view. +// Note: we cannot use the gocui buffer since any state change requires writing the entire tree to the buffer. +// Instead we are keeping an upper and lower bounds of the tree string to render and only flushing +// this range into the view buffer. This is much faster when tree sizes are large. func (view *FileTreeView) CursorUp() error { - // we cannot use the gocui buffer since any state change requires writing the entire tree to the buffer. - // Instead we are keeping an upper and lower bounds of the tree string to render and only flushing - // this range into the view buffer. This is much faster when tree sizes are large. - if view.TreeIndex > 0 { view.doCursorUp() return view.Render() @@ -176,12 +191,13 @@ func (view *FileTreeView) CursorUp() error { return nil } +// getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode. func (view *FileTreeView) getAbsPositionNode() (node *filetree.FileNode) { - var visiter func(*filetree.FileNode) error + var visitor func(*filetree.FileNode) error var evaluator func(*filetree.FileNode) bool var dfsCounter uint - visiter = func(curNode *filetree.FileNode) error { + visitor = func(curNode *filetree.FileNode) error { if dfsCounter == view.TreeIndex { node = curNode } @@ -207,7 +223,7 @@ func (view *FileTreeView) getAbsPositionNode() (node *filetree.FileNode) { return !curNode.Parent.Data.ViewInfo.Collapsed && !curNode.Data.ViewInfo.Hidden && regexMatch } - err = view.ModelTree.VisitDepthParentFirst(visiter, evaluator) + err = view.ModelTree.VisitDepthParentFirst(visitor, evaluator) if err != nil { panic(err) } @@ -215,6 +231,7 @@ func (view *FileTreeView) getAbsPositionNode() (node *filetree.FileNode) { return node } +// toggleCollapse will collapse/expand the selected FileNode. func (view *FileTreeView) toggleCollapse() error { node := view.getAbsPositionNode() if node != nil { @@ -224,17 +241,18 @@ func (view *FileTreeView) toggleCollapse() error { return view.Render() } +// toggleShowDiffType will show/hide the selected DiffType in the filetree pane. func (view *FileTreeView) toggleShowDiffType(diffType filetree.DiffType) error { view.HiddenDiffTypes[diffType] = !view.HiddenDiffTypes[diffType] - view.view.SetCursor(0, 0) - view.TreeIndex = 0 + view.resetCursor() Update() Render() return nil } +// filterRegex will return a regular expression object to match the user's filter input. func filterRegex() *regexp.Regexp { if Views.Filter == nil || Views.Filter.view == nil { return nil @@ -252,6 +270,7 @@ func filterRegex() *regexp.Regexp { return regex } +// Update refreshes the state objects for future rendering. func (view *FileTreeView) Update() error { regex := filterRegex() @@ -282,14 +301,7 @@ func (view *FileTreeView) Update() error { return nil } -func (view *FileTreeView) KeyHelp() string { - return renderStatusOption("Space","Collapse dir", false) + - renderStatusOption("^A","Added files", !view.HiddenDiffTypes[filetree.Added]) + - renderStatusOption("^R","Removed files", !view.HiddenDiffTypes[filetree.Removed]) + - renderStatusOption("^M","Modified files", !view.HiddenDiffTypes[filetree.Changed]) + - renderStatusOption("^U","Unmodified files", !view.HiddenDiffTypes[filetree.Unchanged]) -} - +// Render flushes the state objects (file tree) to the pane. func (view *FileTreeView) Render() error { treeString := view.ViewTree.StringBetween(view.bufferIndexLowerBound, view.bufferIndexUpperBound,true) lines := strings.Split(treeString, "\n") @@ -299,10 +311,22 @@ func (view *FileTreeView) Render() error { view.doCursorUp() } + title := "Current Layer Contents" + if Views.Layer.CompareMode == CompareAll { + title = "Aggregated Layer Contents" + } + + // indicate when selected + if view.gui.CurrentView() == view.view { + title = "● "+title + } + view.gui.Update(func(g *gocui.Gui) error { // update the header view.header.Clear() - headerStr := fmt.Sprintf(filetree.AttributeFormat+" %s", "P", "ermission", "UID:GID", "Size", "Filetree") + width, _ := g.Size() + headerStr := fmt.Sprintf("[%s]%s\n", title, strings.Repeat("─", width*2)) + headerStr += fmt.Sprintf(filetree.AttributeFormat+" %s", "P", "ermission", "UID:GID", "Size", "Filetree") fmt.Fprintln(view.header, Formatting.Header(vtclean.Clean(headerStr, false))) // update the contents @@ -319,3 +343,12 @@ func (view *FileTreeView) Render() error { }) return nil } + +// KeyHelp indicates all the possible actions a user can take while the current pane is selected. +func (view *FileTreeView) KeyHelp() string { + return renderStatusOption("Space","Collapse dir", false) + + renderStatusOption("^A","Added files", !view.HiddenDiffTypes[filetree.Added]) + + renderStatusOption("^R","Removed files", !view.HiddenDiffTypes[filetree.Removed]) + + renderStatusOption("^M","Modified files", !view.HiddenDiffTypes[filetree.Changed]) + + renderStatusOption("^U","Unmodified files", !view.HiddenDiffTypes[filetree.Unchanged]) +} \ No newline at end of file diff --git a/ui/filterview.go b/ui/filterview.go index 5849891..93df764 100644 --- a/ui/filterview.go +++ b/ui/filterview.go @@ -6,8 +6,8 @@ import ( "github.com/jroimartin/gocui" ) -// with special thanks to https://gist.github.com/jroimartin/3b2e943a3811d795e0718b4a95b89bec - +// DetailsView 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 FilterView struct { Name string gui *gocui.Gui @@ -18,25 +18,20 @@ type FilterView struct { hidden bool } -type Input struct { - name string - x, y int - w int - maxLength int -} - -func NewFilterView(name string, gui *gocui.Gui) (filterview *FilterView) { - filterview = new(FilterView) +// NewFilterView creates a new view object attached the the global [gocui] screen object. +func NewFilterView(name string, gui *gocui.Gui) (filterView *FilterView) { + filterView = new(FilterView) // populate main fields - filterview.Name = name - filterview.gui = gui - filterview.headerStr = "Path Filter: " - filterview.hidden = true + filterView.Name = name + filterView.gui = gui + filterView.headerStr = "Path Filter: " + filterView.hidden = true - return filterview + return filterView } +// Setup initializes the UI concerns within the context of a global [gocui] view object. func (view *FilterView) Setup(v *gocui.View, header *gocui.View) error { // set view options @@ -53,32 +48,28 @@ func (view *FilterView) Setup(v *gocui.View, header *gocui.View) error { view.header.Wrap = false view.header.Frame = false - // set keybindings - // if err := view.gui.SetKeybinding(view.Name, gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return view.CursorDown() }); err != nil { - // return err - // } - // if err := view.gui.SetKeybinding(view.Name, gocui.KeyArrowUp, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return view.CursorUp() }); err != nil { - // return err - // } - view.Render() return nil } +// IsVisible indicates if the filter view pane is currently initialized func (view *FilterView) IsVisible() bool { if view == nil {return false} return !view.hidden } +// CursorDown moves the cursor down in the filter pane (currently indicates nothing). func (view *FilterView) CursorDown() error { return nil } +// CursorUp moves the cursor up in the filter pane (currently indicates nothing). func (view *FilterView) CursorUp() error { return nil } +// Edit intercepts the key press events in the filer view to update the file view in real time. func (view *FilterView) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { if !view.IsVisible() { return @@ -101,14 +92,12 @@ func (view *FilterView) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Mo } } -func (view *FilterView) KeyHelp() string { - return Formatting.StatusControlNormal("▏Type to filter the file tree ") -} - +// Update refreshes the state objects for future rendering (currently does nothing). func (view *FilterView) Update() error { return nil } +// Render flushes the state objects to the screen. Currently this is the users path filter input. func (view *FilterView) Render() error { view.gui.Update(func(g *gocui.Gui) error { // render the header @@ -116,6 +105,10 @@ func (view *FilterView) Render() error { return nil }) - // todo: blerg return nil } + +// KeyHelp indicates all the possible actions a user can take while the current pane is selected. +func (view *FilterView) KeyHelp() string { + return Formatting.StatusControlNormal("▏Type to filter the file tree ") +} \ No newline at end of file diff --git a/ui/layerview.go b/ui/layerview.go index 1881ee6..00c8738 100644 --- a/ui/layerview.go +++ b/ui/layerview.go @@ -7,8 +7,11 @@ import ( "github.com/wagoodman/dive/image" "github.com/lunixbochs/vtclean" "github.com/dustin/go-humanize" + "strings" ) +// LayerView holds the UI objects and data models for populating the lower-left pane. Specifically the pane that +// shows the image layers and layer selector. type LayerView struct { Name string gui *gocui.Gui @@ -20,27 +23,26 @@ type LayerView struct { CompareStartIndex int } -func NewLayerView(name string, gui *gocui.Gui, layers []*image.Layer) (layerview *LayerView) { - layerview = new(LayerView) +// NewDetailsView creates a new view object attached the the global [gocui] screen object. +func NewLayerView(name string, gui *gocui.Gui, layers []*image.Layer) (layerView *LayerView) { + layerView = new(LayerView) // populate main fields - layerview.Name = name - layerview.gui = gui - layerview.Layers = layers - layerview.CompareMode = CompareLayer + layerView.Name = name + layerView.gui = gui + layerView.Layers = layers + layerView.CompareMode = CompareLayer - return layerview + return layerView } +// Setup initializes the UI concerns within the context of a global [gocui] view object. func (view *LayerView) Setup(v *gocui.View, header *gocui.View) error { // set view options view.view = v view.view.Editable = false view.view.Wrap = false - //view.view.Highlight = true - //view.view.SelBgColor = gocui.ColorGreen - //view.view.SelFgColor = gocui.ColorBlack view.view.Frame = false view.header = header @@ -65,11 +67,50 @@ func (view *LayerView) Setup(v *gocui.View, header *gocui.View) error { return view.Render() } +// IsVisible indicates if the layer view pane is currently initialized. func (view *LayerView) IsVisible() bool { if view == nil {return false} return true } +// CursorDown moves the cursor down in the layer pane (selecting a higher layer). +func (view *LayerView) CursorDown() error { + if view.LayerIndex < len(view.Layers) { + err := CursorDown(view.gui, view.view) + if err == nil { + view.SetCursor(view.LayerIndex+1) + } + } + return nil +} + +// CursorUp moves the cursor up in the layer pane (selecting a lower layer). +func (view *LayerView) CursorUp() error { + if view.LayerIndex > 0 { + err := CursorUp(view.gui, view.view) + if err == nil { + view.SetCursor(view.LayerIndex-1) + } + } + return nil +} + +// SetCursor resets the cursor and orients the file tree view based on the given layer index. +func (view *LayerView) SetCursor(layer int) error { + view.LayerIndex = layer + Views.Tree.setTreeByLayer(view.getCompareIndexes()) + Views.Details.Render() + view.Render() + + return nil +} + +// currentLayer returns the Layer object currently selected. +func (view *LayerView) currentLayer() *image.Layer { + return view.Layers[(len(view.Layers)-1)-view.LayerIndex] +} + +// setCompareMode switches the layer comparison between a single-layer comparison to an aggregated comparison. func (view *LayerView) setCompareMode(compareMode CompareType) error { view.CompareMode = compareMode Update() @@ -77,6 +118,7 @@ func (view *LayerView) setCompareMode(compareMode CompareType) error { return Views.Tree.setTreeByLayer(view.getCompareIndexes()) } +// getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode) func (view *LayerView) getCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) { bottomTreeStart = view.CompareStartIndex topTreeStop = view.LayerIndex @@ -95,16 +137,11 @@ func (view *LayerView) getCompareIndexes() (bottomTreeStart, bottomTreeStop, top return bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop } +// renderCompareBar returns the formatted string for the given layer. func (view *LayerView) renderCompareBar(layerIdx int) string { bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := view.getCompareIndexes() result := " " - //if debug { - // v, _ := view.gui.View("debug") - // v.Clear() - // _, _ = fmt.Fprintf(v, "bStart: %d bStop: %d tStart: %d tStop: %d", bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) - //} - if layerIdx >= bottomTreeStart && layerIdx <= bottomTreeStop { result = Formatting.CompareBottom(" ") } @@ -112,29 +149,31 @@ func (view *LayerView) renderCompareBar(layerIdx int) string { result = Formatting.CompareTop(" ") } - //if bottomTreeStop == topTreeStart { - // result += " " - //} else { - // if layerIdx == bottomTreeStop { - // result += "─┐" - // } else if layerIdx == topTreeStart { - // result += "─┘" - // } else { - // result += " " - // } - //} - return result } +// Update refreshes the state objects for future rendering (currently does nothing). func (view *LayerView) Update() error { return nil } +// Render flushes the state objects to the screen. The layers pane reports: +// 1. the layers of the image + metadata +// 2. the current selected image func (view *LayerView) Render() error { + + // indicate when selected + title := "Layers" + if view.gui.CurrentView() == view.view { + title = "● "+title + } + view.gui.Update(func(g *gocui.Gui) error { // update header - headerStr := fmt.Sprintf("Cmp "+image.LayerFormat, "Image ID", "%Eff.", "Size", "Filter") + view.header.Clear() + width, _ := g.Size() + headerStr := fmt.Sprintf("[%s]%s\n", title, strings.Repeat("─", width*2)) + headerStr += fmt.Sprintf("Cmp "+image.LayerFormat, "Image ID", "Size", "Command") fmt.Fprintln(view.header, Formatting.Header(vtclean.Clean(headerStr, false))) // update contents @@ -152,7 +191,7 @@ func (view *LayerView) Render() error { layerId = fmt.Sprintf("%-25s", layer.History.ID) } - layerStr = fmt.Sprintf(image.LayerFormat, layerId, "", humanize.Bytes(uint64(layer.History.Size)), "FROM "+layer.Id()) + layerStr = fmt.Sprintf(image.LayerFormat, layerId, humanize.Bytes(uint64(layer.History.Size)), "FROM "+layer.Id()) } compareBar := view.renderCompareBar(idx) @@ -166,46 +205,12 @@ func (view *LayerView) Render() error { } return nil }) - // todo: blerg return nil } -func (view *LayerView) CursorDown() error { - if view.LayerIndex < len(view.Layers) { - err := CursorDown(view.gui, view.view) - if err == nil { - view.LayerIndex++ - Views.Tree.setTreeByLayer(view.getCompareIndexes()) - view.Render() - // debugPrint(fmt.Sprintf("%d",len(filetree.Cache))) - } - } - return nil -} - -func (view *LayerView) CursorUp() error { - if view.LayerIndex > 0 { - err := CursorUp(view.gui, view.view) - if err == nil { - view.LayerIndex-- - Views.Tree.setTreeByLayer(view.getCompareIndexes()) - view.Render() - // debugPrint(fmt.Sprintf("%d",len(filetree.Cache))) - } - } - return nil -} - -func (view *LayerView) SetCursor(layer int) error { - // view.view.SetCursor(0, layer) - view.LayerIndex = layer - Views.Tree.setTreeByLayer(view.getCompareIndexes()) - view.Render() - - return nil -} +// KeyHelp indicates all the possible actions a user can take while the current pane is selected. func (view *LayerView) KeyHelp() string { - return renderStatusOption("^L","Layer changes", view.CompareMode == CompareLayer) + - renderStatusOption("^A","All changes", view.CompareMode == CompareAll) + return renderStatusOption("^L","Show layer changes", view.CompareMode == CompareLayer) + + renderStatusOption("^A","Show aggregated changes", view.CompareMode == CompareAll) } diff --git a/ui/statusview.go b/ui/statusview.go index 766de6c..2180cdd 100644 --- a/ui/statusview.go +++ b/ui/statusview.go @@ -7,57 +7,59 @@ import ( "strings" ) +// DetailsView holds the UI objects and data models for populating the bottom-most pane. Specifcially the panel +// shows the user a set of possible actions to take in the window and currently selected pane. type StatusView struct { Name string gui *gocui.Gui view *gocui.View } -func NewStatusView(name string, gui *gocui.Gui) (statusview *StatusView) { - statusview = new(StatusView) +// NewStatusView creates a new view object attached the the global [gocui] screen object. +func NewStatusView(name string, gui *gocui.Gui) (statusView *StatusView) { + statusView = new(StatusView) // populate main fields - statusview.Name = name - statusview.gui = gui + statusView.Name = name + statusView.gui = gui - return statusview + return statusView } +// Setup initializes the UI concerns within the context of a global [gocui] view object. func (view *StatusView) Setup(v *gocui.View, header *gocui.View) error { // set view options view.view = v view.view.Frame = false - //view.view.BgColor = gocui.ColorDefault + gocui.AttrReverse view.Render() return nil } +// IsVisible indicates if the status view pane is currently initialized. func (view *StatusView) IsVisible() bool { if view == nil {return false} return true } +// CursorDown moves the cursor down in the details pane (currently indicates nothing). func (view *StatusView) CursorDown() error { return nil } +// CursorUp moves the cursor up in the details pane (currently indicates nothing). func (view *StatusView) CursorUp() error { return nil } -func (view *StatusView) KeyHelp() string { - return renderStatusOption("^C","Quit", false) + - renderStatusOption("^Space","Switch view", false) + - renderStatusOption("^/","Filter files", Views.Filter.IsVisible()) -} - +// Update refreshes the state objects for future rendering (currently does nothing). func (view *StatusView) Update() error { return nil } +// Render flushes the state objects to the screen. func (view *StatusView) Render() error { view.gui.Update(func(g *gocui.Gui) error { view.view.Clear() @@ -68,3 +70,10 @@ func (view *StatusView) Render() error { // todo: blerg return nil } + +// KeyHelp indicates all the possible global actions a user can take when any pane is selected. +func (view *StatusView) KeyHelp() string { + return renderStatusOption("^C","Quit", false) + + renderStatusOption("^Space","Switch view", false) + + renderStatusOption("^/","Filter files", Views.Filter.IsVisible()) +} \ No newline at end of file diff --git a/ui/ui.go b/ui/ui.go index 0f11759..54516b4 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -17,6 +17,10 @@ import ( const debug = false const profile = false +var cpuProfilePath *os.File +var memoryProfilePath *os.File + +// debugPrint writes the given string to the debug pane (if the debug pane is enabled) func debugPrint(s string) { if debug && Views.Tree != nil && Views.Tree.gui != nil { v, _ := Views.Tree.gui.View("debug") @@ -29,6 +33,7 @@ func debugPrint(s string) { } } +// Formatting defines standard functions for formatting UI sections. var Formatting struct { Header func(...interface{})(string) Selected func(...interface{})(string) @@ -40,14 +45,17 @@ var Formatting struct { CompareBottom func(...interface{})(string) } +// Views contains all rendered UI panes. var Views struct { - Tree *FileTreeView - Layer *LayerView - Status *StatusView - Filter *FilterView - lookup map[string]View + Tree *FileTreeView + Layer *LayerView + Status *StatusView + Filter *FilterView + Details *DetailsView + lookup map[string]View } +// View defines the a renderable terminal screen pane. type View interface { Setup(*gocui.View, *gocui.View) error CursorDown() error @@ -58,6 +66,7 @@ type View interface { IsVisible() bool } +// toggleView switches between the file view and the layer view and re-renders the screen. func toggleView(g *gocui.Gui, v *gocui.View) error { if v == nil || v.Name() == Views.Layer.Name { _, err := g.SetCurrentView(Views.Tree.Name) @@ -71,6 +80,7 @@ func toggleView(g *gocui.Gui, v *gocui.View) error { return err } +// toggleFilterView shows/hides the file tree filter pane. func toggleFilterView(g *gocui.Gui, v *gocui.View) error { // delete all user input from the tree view Views.Filter.view.Clear() @@ -93,6 +103,7 @@ func toggleFilterView(g *gocui.Gui, v *gocui.View) error { return nil } +// CursorDown moves the cursor down in the currently selected gocui pane, scrolling the screen as needed. func CursorDown(g *gocui.Gui, v *gocui.View) error { cx, cy := v.Cursor() @@ -113,6 +124,7 @@ func CursorDown(g *gocui.Gui, v *gocui.View) error { return nil } +// CursorUp moves the cursor up in the currently selected gocui pane, scrolling the screen as needed. func CursorUp(g *gocui.Gui, v *gocui.View) error { ox, oy := v.Origin() cx, cy := v.Cursor() @@ -124,10 +136,7 @@ func CursorUp(g *gocui.Gui, v *gocui.View) error { return nil } - -var cpuProfilePath *os.File -var memoryProfilePath *os.File - +// quit is the gocui callback invoked when the user hits Ctrl+C func quit(g *gocui.Gui, v *gocui.View) error { if profile { pprof.StopCPUProfile() @@ -139,7 +148,8 @@ func quit(g *gocui.Gui, v *gocui.View) error { return gocui.ErrQuit } -func keybindings(g *gocui.Gui) error { +// keyBindings registers global key press actions, valid when in any pane. +func keyBindings(g *gocui.Gui) error { if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { return err } @@ -156,6 +166,7 @@ func keybindings(g *gocui.Gui) error { return nil } +// isNewView determines if a view has already been created based on the set of errors given (a bit hokie) func isNewView(errs ...error) bool { for _, err := range errs { if err == nil { @@ -168,8 +179,12 @@ func isNewView(errs ...error) bool { return true } -// TODO: this logic should be refactored into an abstraction that takes care of the math for us + +// layout defines the definition of the window pane size and placement relations to one another. This +// is invoked at application start and whenever the screen dimensions change. func layout(g *gocui.Gui) error { + // TODO: this logic should be refactored into an abstraction that takes care of the math for us + maxX, maxY := g.Size() splitCols := maxX / 2 debugWidth := 0 @@ -178,7 +193,7 @@ func layout(g *gocui.Gui) error { } debugCols := maxX - debugWidth bottomRows := 1 - headerRows := 1 + headerRows := 2 filterBarHeight := 1 statusBarHeight := 1 @@ -186,6 +201,8 @@ func layout(g *gocui.Gui) error { statusBarIndex := 1 filterBarIndex := 2 + layersHeight := len(Views.Layer.Layers) + headerRows + 1 // layers + header + base image layer row + var view, header *gocui.View var viewErr, headerErr, err error @@ -204,7 +221,7 @@ func layout(g *gocui.Gui) error { } // Layers - view, viewErr = g.SetView(Views.Layer.Name, -1, -1+headerRows, splitCols, maxY-bottomRows) + view, viewErr = g.SetView(Views.Layer.Name, -1, -1+headerRows, splitCols, layersHeight) header, headerErr = g.SetView(Views.Layer.Name+"header", -1, -1, splitCols, headerRows) if isNewView(viewErr, headerErr) { Views.Layer.Setup(view, header) @@ -212,6 +229,15 @@ func layout(g *gocui.Gui) error { if _, err = g.SetCurrentView(Views.Layer.Name); err != nil { return err } + // since we are selecting the view, we should rerender to indicate it is selected + Views.Layer.Render() + } + + // Details + view, viewErr = g.SetView(Views.Details.Name, -1, -1+layersHeight+headerRows, splitCols, maxY-bottomRows) + header, headerErr = g.SetView(Views.Details.Name+"header", -1, -1+layersHeight, splitCols, layersHeight+headerRows) + if isNewView(viewErr, headerErr) { + Views.Details.Setup(view, header) } // Filetree @@ -238,12 +264,14 @@ func layout(g *gocui.Gui) error { return nil } +// Update refreshes the state objects for future rendering. func Update() { for _, view := range Views.lookup { view.Update() } } +// Render flushes the state objects to the screen. func Render() { for _, view := range Views.lookup { if view.IsVisible() { @@ -252,6 +280,7 @@ func Render() { } } +// renderStatusOption formats key help bindings-to-title pairs. func renderStatusOption(control, title string, selected bool) string { if selected { return Formatting.StatusSelected("▏") + Formatting.StatusControlSelected(control) + Formatting.StatusSelected(" " + title + " ") @@ -260,6 +289,7 @@ func renderStatusOption(control, title string, selected bool) string { } } +// Run is the UI entrypoint. func Run(layers []*image.Layer, refTrees []*filetree.FileTree) { Formatting.Selected = color.New(color.ReverseVideo, color.Bold).SprintFunc() @@ -291,14 +321,22 @@ func Run(layers []*image.Layer, refTrees []*filetree.FileTree) { Views.Filter = NewFilterView("command", g) Views.lookup[Views.Filter.Name] = Views.Filter + Views.Details = NewDetailsView("details", g) + Views.lookup[Views.Details.Name] = Views.Details + + g.Cursor = false //g.Mouse = true g.SetManagerFunc(layout) + // perform the first update and render now that all resources have been loaded + Update() + Render() + // let the default position of the cursor be the last layer // Views.Layer.SetCursor(len(Views.Layer.Layers)-1) - if err := keybindings(g); err != nil { + if err := keyBindings(g); err != nil { log.Panicln(err) }