From 993be8d3ae178d7c62443e91f9a206777d6ed1ac Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Fri, 22 Feb 2019 11:49:53 -0500 Subject: [PATCH] Filetree improvements (#165) * add filetree viewmodel * added attribute toggle * these views are really controllers * fix collapse all dir when selected file * determine filetree upperbound dynamically * support bounding cursor movements in the view model * added first view model test case * added test cases for filetree viewmodel --- README.md | 8 +- cmd/root.go | 2 + filetree/tree.go | 22 +- go.mod | 1 + go.sum | 2 + image/docker_layer.go | 13 +- ui/details_controller.go | 151 +++++ ui/detailsview.go | 151 ----- ui/filetree_controller.go | 395 +++++++++++ ui/filetree_viewmodel.go | 426 ++++++++++++ ui/filetree_viewmodel_test.go | 387 +++++++++++ ui/filetreeview.go | 634 ------------------ ui/filter_controller.go | 116 ++++ ui/filterview.go | 116 ---- ui/layer_controller.go | 315 +++++++++ ui/layerview.go | 309 --------- ui/status_controller.go | 81 +++ ui/statusview.go | 81 --- ui/testdata/TestFileShowAggregateChanges.txt | 36 + ui/testdata/TestFileTreeDirCollapse.txt | 13 + ui/testdata/TestFileTreeDirCollapseAll.txt | 9 + ui/testdata/TestFileTreeDirCursorRight.txt | 22 + ui/testdata/TestFileTreeFilterTree.txt | 7 + ui/testdata/TestFileTreeGoCase.txt | 416 ++++++++++++ .../TestFileTreeHideAddedRemovedModified.txt | 21 + .../TestFileTreeHideTypeWithFilter.txt | 1 + ui/testdata/TestFileTreeHideUnmodified.txt | 10 + ui/testdata/TestFileTreeNoAttributes.txt | 416 ++++++++++++ ui/testdata/TestFileTreePageDown.txt | 11 + ui/testdata/TestFileTreePageUp.txt | 11 + ui/testdata/TestFileTreeRestrictedHeight.txt | 22 + ui/testdata/TestFileTreeSelectLayer.txt | 23 + ui/ui.go | 122 ++-- 33 files changed, 2991 insertions(+), 1359 deletions(-) create mode 100644 ui/details_controller.go delete mode 100644 ui/detailsview.go create mode 100644 ui/filetree_controller.go create mode 100644 ui/filetree_viewmodel.go create mode 100644 ui/filetree_viewmodel_test.go delete mode 100644 ui/filetreeview.go create mode 100644 ui/filter_controller.go delete mode 100644 ui/filterview.go create mode 100644 ui/layer_controller.go delete mode 100644 ui/layerview.go create mode 100644 ui/status_controller.go delete mode 100644 ui/statusview.go create mode 100644 ui/testdata/TestFileShowAggregateChanges.txt create mode 100644 ui/testdata/TestFileTreeDirCollapse.txt create mode 100644 ui/testdata/TestFileTreeDirCollapseAll.txt create mode 100644 ui/testdata/TestFileTreeDirCursorRight.txt create mode 100644 ui/testdata/TestFileTreeFilterTree.txt create mode 100644 ui/testdata/TestFileTreeGoCase.txt create mode 100644 ui/testdata/TestFileTreeHideAddedRemovedModified.txt create mode 100644 ui/testdata/TestFileTreeHideTypeWithFilter.txt create mode 100644 ui/testdata/TestFileTreeHideUnmodified.txt create mode 100644 ui/testdata/TestFileTreeNoAttributes.txt create mode 100644 ui/testdata/TestFileTreePageDown.txt create mode 100644 ui/testdata/TestFileTreePageUp.txt create mode 100644 ui/testdata/TestFileTreeRestrictedHeight.txt create mode 100644 ui/testdata/TestFileTreeSelectLayer.txt diff --git a/README.md b/README.md index 1cea4f9..3445229 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ Key Binding | Description Ctrl + R | Filetree view: show/hide removed files Ctrl + M | Filetree view: show/hide modified files Ctrl + U | Filetree view: show/hide unmodified files +Ctrl + B | Filetree view: show/hide file attributes ## UI Configuration @@ -203,7 +204,7 @@ log: keybinding: # Global bindings quit: ctrl+c - toggle-view: tab, ctrl+space + toggle-view: tab filter-files: ctrl+f, ctrl+slash # Layer view specific bindings @@ -212,10 +213,12 @@ keybinding: # File view specific bindings toggle-collapse-dir: space + toggle-collapse-all-dir: ctrl+space toggle-added-files: ctrl+a toggle-removed-files: ctrl+r toggle-modified-files: ctrl+m toggle-unmodified-files: ctrl+u + toggle-filetree-attributes: ctrl+b page-up: pgup page-down: pgdn @@ -233,6 +236,9 @@ filetree: # The percentage of screen width the filetree should take on the screen (must be >0 and <1) pane-width: 0.5 + + # Show the file attributes next to the filetree + show-attributes: true layer: # Enable showing all changes from this layer and ever previous layer diff --git a/cmd/root.go b/cmd/root.go index ad8d0ce..b816a16 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -67,6 +67,7 @@ func initConfig() { // keybindings: filetree view viper.SetDefault("keybinding.toggle-collapse-dir", "space") viper.SetDefault("keybinding.toggle-collapse-all-dir", "ctrl+space") + viper.SetDefault("keybinding.toggle-filetree-attributes", "ctrl+b") viper.SetDefault("keybinding.toggle-added-files", "ctrl+a") viper.SetDefault("keybinding.toggle-removed-files", "ctrl+r") viper.SetDefault("keybinding.toggle-modified-files", "ctrl+m") @@ -80,6 +81,7 @@ func initConfig() { viper.SetDefault("filetree.collapse-dir", false) viper.SetDefault("filetree.pane-width", 0.5) + viper.SetDefault("filetree.show-attributes", true) viper.AutomaticEnv() // read in environment variables that match diff --git a/filetree/tree.go b/filetree/tree.go index b4584ff..b9cc834 100644 --- a/filetree/tree.go +++ b/filetree/tree.go @@ -119,14 +119,32 @@ func (tree *FileTree) renderStringTreeBetween(startRow, stopRow int, showAttribu return result } +func (tree *FileTree) VisibleSize() int { + var size int + + visitor := func(node *FileNode) error { + size++ + return nil + } + visitEvaluator := func(node *FileNode) bool { + return !node.Data.ViewInfo.Collapsed && !node.Data.ViewInfo.Hidden + } + err := tree.VisitDepthParentFirst(visitor, visitEvaluator) + if err != nil { + logrus.Errorf("unable to determine visible tree size: %+v", err) + } + + return size +} + // 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) +func (tree *FileTree) StringBetween(start, stop int, showAttributes bool) string { + return tree.renderStringTreeBetween(start, stop, showAttributes) } // Copy returns a copy of the given FileTree diff --git a/go.mod b/go.mod index 3d0af88..d6e5025 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/opencontainers/image-spec v1.0.1 // indirect github.com/phayes/permbits v0.0.0-20180830030258-59f2482cd460 github.com/pkg/errors v0.8.0 // indirect + github.com/sergi/go-diff v1.0.0 github.com/sirupsen/logrus v1.2.0 github.com/spf13/cobra v0.0.3 github.com/spf13/viper v1.2.1 diff --git a/go.sum b/go.sum index 7270f0b..fd2fa56 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,8 @@ github.com/phayes/permbits v0.0.0-20180830030258-59f2482cd460/go.mod h1:3uODdxMg github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= diff --git a/image/docker_layer.go b/image/docker_layer.go index b5146b8..d2cbaef 100644 --- a/image/docker_layer.go +++ b/image/docker_layer.go @@ -8,7 +8,8 @@ import ( ) const ( - LayerFormat = "%-25s %7s %s" + // LayerFormat = "%-15s %7s %s" + LayerFormat = "%7s %s" ) // ShortId returns the truncated id of the current layer. @@ -43,9 +44,9 @@ func (layer *dockerLayer) Command() string { // ShortId returns the truncated id of the current layer. func (layer *dockerLayer) ShortId() string { - rangeBound := 25 + rangeBound := 15 id := layer.Id() - if length := len(id); length < 25 { + if length := len(id); length < 15 { rangeBound = length } id = id[0:rangeBound] @@ -63,12 +64,14 @@ func (layer *dockerLayer) String() string { if layer.index == 0 { return fmt.Sprintf(LayerFormat, - layer.ShortId(), + // layer.ShortId(), + // fmt.Sprintf("%d",layer.Index()), humanize.Bytes(layer.Size()), "FROM "+layer.ShortId()) } return fmt.Sprintf(LayerFormat, - layer.ShortId(), + // layer.ShortId(), + // fmt.Sprintf("%d",layer.Index()), humanize.Bytes(layer.Size()), layer.Command()) } diff --git a/ui/details_controller.go b/ui/details_controller.go new file mode 100644 index 0000000..68dd5df --- /dev/null +++ b/ui/details_controller.go @@ -0,0 +1,151 @@ +package ui + +import ( + "fmt" + "github.com/dustin/go-humanize" + "github.com/jroimartin/gocui" + "github.com/lunixbochs/vtclean" + "github.com/wagoodman/dive/filetree" + "strconv" + "strings" +) + +// DetailsController 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 DetailsController struct { + Name string + gui *gocui.Gui + view *gocui.View + header *gocui.View + efficiency float64 + inefficiencies filetree.EfficiencySlice +} + +// NewDetailsController creates a new view object attached the the global [gocui] screen object. +func NewDetailsController(name string, gui *gocui.Gui, efficiency float64, inefficiencies filetree.EfficiencySlice) (controller *DetailsController) { + controller = new(DetailsController) + + // populate main fields + controller.Name = name + controller.gui = gui + controller.efficiency = efficiency + controller.inefficiencies = inefficiencies + + return controller +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (controller *DetailsController) Setup(v *gocui.View, header *gocui.View) error { + + // set controller options + controller.view = v + controller.view.Editable = false + controller.view.Wrap = true + controller.view.Highlight = false + controller.view.Frame = false + + controller.header = header + controller.header.Editable = false + controller.header.Wrap = false + controller.header.Frame = false + + // set keybindings + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorDown() }); err != nil { + return err + } + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowUp, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorUp() }); err != nil { + return err + } + + return controller.Render() +} + +// IsVisible indicates if the details view pane is currently initialized. +func (controller *DetailsController) IsVisible() bool { + if controller == nil { + return false + } + return true +} + +// CursorDown moves the cursor down in the details pane (currently indicates nothing). +func (controller *DetailsController) CursorDown() error { + return CursorDown(controller.gui, controller.view) +} + +// CursorUp moves the cursor up in the details pane (currently indicates nothing). +func (controller *DetailsController) CursorUp() error { + return CursorUp(controller.gui, controller.view) +} + +// Update refreshes the state objects for future rendering. +func (controller *DetailsController) Update() error { + 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 (controller *DetailsController) Render() error { + currentLayer := Controllers.Layer.currentLayer() + + var wastedSpace int64 + + template := "%5s %12s %-s\n" + inefficiencyReport := fmt.Sprintf(Formatting.Header(template), "Count", "Total Space", "Path") + + height := 100 + if controller.view != nil { + _, height = controller.view.Size() + } + + for idx := 0; idx < len(controller.inefficiencies); idx++ { + data := controller.inefficiencies[len(controller.inefficiencies)-1-idx] + wastedSpace += data.CumulativeSize + + // todo: make this report scrollable + if idx < height { + inefficiencyReport += fmt.Sprintf(template, strconv.Itoa(len(data.Nodes)), humanize.Bytes(uint64(data.CumulativeSize)), data.Path) + } + } + + imageSizeStr := fmt.Sprintf("%s %s", Formatting.Header("Total Image size:"), humanize.Bytes(Controllers.Layer.ImageSize)) + effStr := fmt.Sprintf("%s %d %%", Formatting.Header("Image efficiency score:"), int(100.0*controller.efficiency)) + wastedSpaceStr := fmt.Sprintf("%s %s", Formatting.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace))) + + controller.gui.Update(func(g *gocui.Gui) error { + // update header + controller.header.Clear() + width, _ := controller.view.Size() + + layerHeaderStr := fmt.Sprintf("[Layer Details]%s", strings.Repeat("─", width-15)) + imageHeaderStr := fmt.Sprintf("[Image Details]%s", strings.Repeat("─", width-15)) + + fmt.Fprintln(controller.header, Formatting.Header(vtclean.Clean(layerHeaderStr, false))) + + // update contents + controller.view.Clear() + fmt.Fprintln(controller.view, Formatting.Header("Digest: ")+currentLayer.Id()) + // TODO: add back in with controller model + // fmt.Fprintln(view.view, Formatting.Header("Tar ID: ")+currentLayer.TarId()) + fmt.Fprintln(controller.view, Formatting.Header("Command:")) + fmt.Fprintln(controller.view, currentLayer.Command()) + + fmt.Fprintln(controller.view, "\n"+Formatting.Header(vtclean.Clean(imageHeaderStr, false))) + + fmt.Fprintln(controller.view, imageSizeStr) + fmt.Fprintln(controller.view, wastedSpaceStr) + fmt.Fprintln(controller.view, effStr+"\n") + + fmt.Fprintln(controller.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 (controller *DetailsController) KeyHelp() string { + return "TBD" +} diff --git a/ui/detailsview.go b/ui/detailsview.go deleted file mode 100644 index 6e5a9e2..0000000 --- a/ui/detailsview.go +++ /dev/null @@ -1,151 +0,0 @@ -package ui - -import ( - "fmt" - "github.com/dustin/go-humanize" - "github.com/jroimartin/gocui" - "github.com/lunixbochs/vtclean" - "github.com/wagoodman/dive/filetree" - "strconv" - "strings" -) - -// 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, efficiency float64, inefficiencies filetree.EfficiencySlice) (detailsView *DetailsView) { - detailsView = new(DetailsView) - - // populate main fields - detailsView.Name = name - detailsView.gui = gui - detailsView.efficiency = efficiency - detailsView.inefficiencies = inefficiencies - - 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. -func (view *DetailsView) Update() error { - 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" - inefficiencyReport := fmt.Sprintf(Formatting.Header(template), "Count", "Total Space", "Path") - - height := 100 - if view.view != nil { - _, height = view.view.Size() - } - - for idx := 0; idx < len(view.inefficiencies); idx++ { - data := view.inefficiencies[len(view.inefficiencies)-1-idx] - wastedSpace += data.CumulativeSize - - // todo: make this report scrollable - if idx < height { - inefficiencyReport += fmt.Sprintf(template, strconv.Itoa(len(data.Nodes)), humanize.Bytes(uint64(data.CumulativeSize)), data.Path) - } - } - - imageSizeStr := fmt.Sprintf("%s %s", Formatting.Header("Total Image size:"), humanize.Bytes(Views.Layer.ImageSize)) - effStr := fmt.Sprintf("%s %d %%", Formatting.Header("Image efficiency score:"), int(100.0*view.efficiency)) - wastedSpaceStr := fmt.Sprintf("%s %s", Formatting.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace))) - - view.gui.Update(func(g *gocui.Gui) error { - // update header - view.header.Clear() - width, _ := view.view.Size() - - layerHeaderStr := fmt.Sprintf("[Layer Details]%s", strings.Repeat("─", width-15)) - imageHeaderStr := fmt.Sprintf("[Image Details]%s", strings.Repeat("─", width-15)) - - fmt.Fprintln(view.header, Formatting.Header(vtclean.Clean(layerHeaderStr, false))) - - // update contents - view.view.Clear() - fmt.Fprintln(view.view, Formatting.Header("Digest: ")+currentLayer.Id()) - // TODO: add back in with view model - // fmt.Fprintln(view.view, Formatting.Header("Tar ID: ")+currentLayer.TarId()) - fmt.Fprintln(view.view, Formatting.Header("Command:")) - fmt.Fprintln(view.view, currentLayer.Command()) - - fmt.Fprintln(view.view, "\n"+Formatting.Header(vtclean.Clean(imageHeaderStr, false))) - - fmt.Fprintln(view.view, imageSizeStr) - fmt.Fprintln(view.view, wastedSpaceStr) - fmt.Fprintln(view.view, effStr+"\n") - - 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/filetree_controller.go b/ui/filetree_controller.go new file mode 100644 index 0000000..29cd389 --- /dev/null +++ b/ui/filetree_controller.go @@ -0,0 +1,395 @@ +package ui + +import ( + "fmt" + "github.com/lunixbochs/vtclean" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/wagoodman/keybinding" + "regexp" + "strings" + + "github.com/jroimartin/gocui" + "github.com/wagoodman/dive/filetree" +) + +const ( + CompareLayer CompareType = iota + CompareAll +) + +type CompareType int + +// FileTreeController 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 FileTreeController struct { + Name string + gui *gocui.Gui + view *gocui.View + header *gocui.View + vm *FileTreeViewModel + + keybindingToggleCollapse []keybinding.Key + keybindingToggleCollapseAll []keybinding.Key + keybindingToggleAttributes []keybinding.Key + keybindingToggleAdded []keybinding.Key + keybindingToggleRemoved []keybinding.Key + keybindingToggleModified []keybinding.Key + keybindingToggleUnchanged []keybinding.Key + keybindingPageDown []keybinding.Key + keybindingPageUp []keybinding.Key +} + +// NewFileTreeController creates a new view object attached the the global [gocui] screen object. +func NewFileTreeController(name string, gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (controller *FileTreeController) { + controller = new(FileTreeController) + + // populate main fields + controller.Name = name + controller.gui = gui + controller.vm = NewFileTreeViewModel(tree, refTrees, cache) + + var err error + controller.keybindingToggleCollapse, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-collapse-dir")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleCollapseAll, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-collapse-all-dir")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleAttributes, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-filetree-attributes")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleAdded, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-added-files")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleRemoved, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-removed-files")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleModified, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-modified-files")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleUnchanged, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-unchanged-files")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingPageUp, err = keybinding.ParseAll(viper.GetString("keybinding.page-up")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingPageDown, err = keybinding.ParseAll(viper.GetString("keybinding.page-down")) + if err != nil { + logrus.Error(err) + } + + return controller +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (controller *FileTreeController) Setup(v *gocui.View, header *gocui.View) error { + + // set controller options + controller.view = v + controller.view.Editable = false + controller.view.Wrap = false + controller.view.Frame = false + + controller.header = header + controller.header.Editable = false + controller.header.Wrap = false + controller.header.Frame = false + + // set keybindings + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorDown() }); err != nil { + return err + } + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowUp, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorUp() }); err != nil { + return err + } + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowLeft, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorLeft() }); err != nil { + return err + } + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowRight, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorRight() }); err != nil { + return err + } + + for _, key := range controller.keybindingPageUp { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.PageUp() }); err != nil { + return err + } + } + for _, key := range controller.keybindingPageDown { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.PageDown() }); err != nil { + return err + } + } + for _, key := range controller.keybindingToggleCollapse { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.toggleCollapse() }); err != nil { + return err + } + } + for _, key := range controller.keybindingToggleCollapseAll { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.toggleCollapseAll() }); err != nil { + return err + } + } + for _, key := range controller.keybindingToggleAttributes { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.toggleAttributes() }); err != nil { + return err + } + } + for _, key := range controller.keybindingToggleAdded { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.toggleShowDiffType(filetree.Added) }); err != nil { + return err + } + } + for _, key := range controller.keybindingToggleRemoved { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.toggleShowDiffType(filetree.Removed) }); err != nil { + return err + } + } + for _, key := range controller.keybindingToggleModified { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.toggleShowDiffType(filetree.Changed) }); err != nil { + return err + } + } + for _, key := range controller.keybindingToggleUnchanged { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.toggleShowDiffType(filetree.Unchanged) }); err != nil { + return err + } + } + + _, height := controller.view.Size() + controller.vm.Setup(0, height) + controller.Update() + controller.Render() + + return nil +} + +// IsVisible indicates if the file tree view pane is currently initialized +func (controller *FileTreeController) IsVisible() bool { + if controller == 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 (controller *FileTreeController) resetCursor() { + controller.view.SetCursor(0, 0) + controller.vm.resetCursor() +} + +// setTreeByLayer populates the view model by stacking the indicated image layer file trees. +func (controller *FileTreeController) setTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { + err := controller.vm.setTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) + if err != nil { + return err + } + // controller.resetCursor() + + controller.Update() + return controller.Render() +} + +// 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 (controller *FileTreeController) CursorDown() error { + if controller.vm.CursorDown() { + return controller.Render() + } + return nil +} + +// 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 (controller *FileTreeController) CursorUp() error { + if controller.vm.CursorUp() { + return controller.Render() + } + return nil +} + +// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree +func (controller *FileTreeController) CursorLeft() error { + err := controller.vm.CursorLeft(filterRegex()) + if err != nil { + return err + } + controller.Update() + return controller.Render() +} + +// CursorRight descends into directory expanding it if needed +func (controller *FileTreeController) CursorRight() error { + err := controller.vm.CursorRight(filterRegex()) + if err != nil { + return err + } + controller.Update() + return controller.Render() +} + +// PageDown moves to next page putting the cursor on top +func (controller *FileTreeController) PageDown() error { + err := controller.vm.PageDown() + if err != nil { + return err + } + return controller.Render() +} + +// PageUp moves to previous page putting the cursor on top +func (controller *FileTreeController) PageUp() error { + err := controller.vm.PageUp() + if err != nil { + return err + } + return controller.Render() +} + +// getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode. +func (controller *FileTreeController) getAbsPositionNode() (node *filetree.FileNode) { + return controller.vm.getAbsPositionNode(filterRegex()) +} + +// toggleCollapse will collapse/expand the selected FileNode. +func (controller *FileTreeController) toggleCollapse() error { + err := controller.vm.toggleCollapse(filterRegex()) + if err != nil { + return err + } + controller.Update() + return controller.Render() +} + +// toggleCollapseAll will collapse/expand the all directories. +func (controller *FileTreeController) toggleCollapseAll() error { + err := controller.vm.toggleCollapseAll() + if err != nil { + return err + } + controller.Update() + return controller.Render() +} + +// toggleAttributes will show/hide file attributes +func (controller *FileTreeController) toggleAttributes() error { + err := controller.vm.toggleAttributes() + if err != nil { + return err + } + // we need to render the changes to the status pane as well + Update() + Render() + return nil +} + +// toggleShowDiffType will show/hide the selected DiffType in the filetree pane. +func (controller *FileTreeController) toggleShowDiffType(diffType filetree.DiffType) error { + controller.vm.toggleShowDiffType(diffType) + // we need to render the changes to the status pane as well + Update() + Render() + return nil +} + +// filterRegex will return a regular expression object to match the user's filter input. +func filterRegex() *regexp.Regexp { + if Controllers.Filter == nil || Controllers.Filter.view == nil { + return nil + } + filterString := strings.TrimSpace(Controllers.Filter.view.Buffer()) + if len(filterString) == 0 { + return nil + } + + regex, err := regexp.Compile(filterString) + if err != nil { + return nil + } + + return regex +} + +// onLayoutChange is called by the UI framework to inform the view-model of the new screen dimensions +func (controller *FileTreeController) onLayoutChange() error { + controller.Update() + return controller.Render() +} + +// Update refreshes the state objects for future rendering. +func (controller *FileTreeController) Update() error { + var width, height int + + if controller.view != nil { + width, height = controller.view.Size() + } else { + // before the TUI is setup there may not be a controller to reference. Use the entire screen as reference. + width, height = controller.gui.Size() + } + // height should account for the header + return controller.vm.Update(filterRegex(), width, height-1) +} + +// Render flushes the state objects (file tree) to the pane. +func (controller *FileTreeController) Render() error { + title := "Current Layer Contents" + if Controllers.Layer.CompareMode == CompareAll { + title = "Aggregated Layer Contents" + } + + // indicate when selected + if controller.gui.CurrentView() == controller.view { + title = "● " + title + } + + controller.gui.Update(func(g *gocui.Gui) error { + // update the header + controller.header.Clear() + width, _ := g.Size() + headerStr := fmt.Sprintf("[%s]%s\n", title, strings.Repeat("─", width*2)) + if controller.vm.ShowAttributes { + headerStr += fmt.Sprintf(filetree.AttributeFormat+" %s", "P", "ermission", "UID:GID", "Size", "Filetree") + } + + fmt.Fprintln(controller.header, Formatting.Header(vtclean.Clean(headerStr, false))) + + // update the contents + controller.view.Clear() + controller.vm.Render() + fmt.Fprint(controller.view, controller.vm.mainBuf.String()) + + return nil + }) + return nil +} + +// KeyHelp indicates all the possible actions a user can take while the current pane is selected. +func (controller *FileTreeController) KeyHelp() string { + return renderStatusOption(controller.keybindingToggleCollapse[0].String(), "Collapse dir", false) + + renderStatusOption(controller.keybindingToggleCollapseAll[0].String(), "Collapse all dir", false) + + renderStatusOption(controller.keybindingToggleAdded[0].String(), "Added", !controller.vm.HiddenDiffTypes[filetree.Added]) + + renderStatusOption(controller.keybindingToggleRemoved[0].String(), "Removed", !controller.vm.HiddenDiffTypes[filetree.Removed]) + + renderStatusOption(controller.keybindingToggleModified[0].String(), "Modified", !controller.vm.HiddenDiffTypes[filetree.Changed]) + + renderStatusOption(controller.keybindingToggleUnchanged[0].String(), "Unmodified", !controller.vm.HiddenDiffTypes[filetree.Unchanged]) + + renderStatusOption(controller.keybindingToggleAttributes[0].String(), "Attributes", controller.vm.ShowAttributes) +} diff --git a/ui/filetree_viewmodel.go b/ui/filetree_viewmodel.go new file mode 100644 index 0000000..7394070 --- /dev/null +++ b/ui/filetree_viewmodel.go @@ -0,0 +1,426 @@ +package ui + +import ( + "bytes" + "fmt" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/wagoodman/dive/utils" + "regexp" + "strings" + + "github.com/lunixbochs/vtclean" + "github.com/wagoodman/dive/filetree" +) + +// 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.TreeCache + + CollapseAll bool + ShowAttributes bool + HiddenDiffTypes []bool + TreeIndex int + bufferIndex int + bufferIndexLowerBound int + + refHeight int + refWidth int + + mainBuf bytes.Buffer +} + +// NewFileTreeController creates a new view object attached the the global [gocui] screen object. +func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (treeViewModel *FileTreeViewModel) { + treeViewModel = new(FileTreeViewModel) + + // populate main fields + treeViewModel.ShowAttributes = viper.GetBool("filetree.show-attributes") + treeViewModel.CollapseAll = viper.GetBool("filetree.collapse-dir") + treeViewModel.ModelTree = tree + treeViewModel.RefTrees = refTrees + treeViewModel.cache = cache + treeViewModel.HiddenDiffTypes = make([]bool, 4) + + hiddenTypes := viper.GetStringSlice("diff.hide") + for _, hType := range hiddenTypes { + switch t := strings.ToLower(hType); t { + case "added": + treeViewModel.HiddenDiffTypes[filetree.Added] = true + case "removed": + treeViewModel.HiddenDiffTypes[filetree.Removed] = true + case "changed": + treeViewModel.HiddenDiffTypes[filetree.Changed] = true + case "unchanged": + treeViewModel.HiddenDiffTypes[filetree.Unchanged] = true + default: + utils.PrintAndExit(fmt.Sprintf("unknown diff.hide value: %s", t)) + } + } + + return treeViewModel +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (vm *FileTreeViewModel) Setup(lowerBound, height int) { + vm.bufferIndexLowerBound = lowerBound + vm.refHeight = height +} + +// height returns the current height and considers the header +func (vm *FileTreeViewModel) height() int { + if vm.ShowAttributes { + return vm.refHeight - 1 + } + return vm.refHeight +} + +// bufferIndexUpperBound returns the current upper bounds for the view +func (vm *FileTreeViewModel) bufferIndexUpperBound() int { + return vm.bufferIndexLowerBound + vm.height() +} + +// IsVisible indicates if the file tree view pane is currently initialized +func (vm *FileTreeViewModel) IsVisible() bool { + if vm == 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 (vm *FileTreeViewModel) resetCursor() { + vm.TreeIndex = 0 + vm.bufferIndex = 0 + vm.bufferIndexLowerBound = 0 +} + +// setTreeByLayer populates the view model by stacking the indicated image layer file trees. +func (vm *FileTreeViewModel) setTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { + if topTreeStop > len(vm.RefTrees)-1 { + return fmt.Errorf("invalid layer index given: %d of %d", topTreeStop, len(vm.RefTrees)-1) + } + newTree := vm.cache.Get(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) + + // preserve vm state on copy + visitor := func(node *filetree.FileNode) error { + newNode, err := newTree.GetNode(node.Path()) + if err == nil { + newNode.Data.ViewInfo = node.Data.ViewInfo + } + return nil + } + err := vm.ModelTree.VisitDepthChildFirst(visitor, nil) + if err != nil { + logrus.Errorf("unable to propagate layer tree: %+v", err) + return 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. +func (vm *FileTreeViewModel) CursorUp() bool { + if vm.TreeIndex <= 0 { + return false + } + vm.TreeIndex-- + if vm.TreeIndex < vm.bufferIndexLowerBound { + vm.bufferIndexLowerBound-- + } + if vm.bufferIndex > 0 { + vm.bufferIndex-- + } + return true +} + +// doCursorDown 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 + } + vm.TreeIndex++ + if vm.TreeIndex > vm.bufferIndexUpperBound() { + vm.bufferIndexLowerBound++ + } + vm.bufferIndex++ + if vm.bufferIndex > vm.height() { + vm.bufferIndex = vm.height() + } + return true +} + +// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree +func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error { + var visitor func(*filetree.FileNode) error + var evaluator func(*filetree.FileNode) bool + var dfsCounter, newIndex int + oldIndex := vm.TreeIndex + currentNode := vm.getAbsPositionNode(filterRegex) + + if currentNode == nil { + return nil + } + parentPath := currentNode.Parent.Path() + + visitor = func(curNode *filetree.FileNode) error { + if strings.Compare(parentPath, curNode.Path()) == 0 { + newIndex = dfsCounter + } + dfsCounter++ + return nil + } + + evaluator = func(curNode *filetree.FileNode) bool { + regexMatch := true + if filterRegex != nil { + match := filterRegex.Find([]byte(curNode.Path())) + regexMatch = match != nil + } + return !curNode.Parent.Data.ViewInfo.Collapsed && !curNode.Data.ViewInfo.Hidden && regexMatch + } + + err := vm.ModelTree.VisitDepthParentFirst(visitor, evaluator) + if err != nil { + logrus.Errorf("could not propagate tree on cursorLeft: %+v", err) + return err + } + + vm.TreeIndex = newIndex + moveIndex := oldIndex - newIndex + if newIndex < vm.bufferIndexLowerBound { + vm.bufferIndexLowerBound = vm.TreeIndex + } + + if vm.bufferIndex > moveIndex { + vm.bufferIndex = vm.bufferIndex - moveIndex + } else { + vm.bufferIndex = 0 + } + + return nil +} + +// CursorRight descends into directory expanding it if needed +func (vm *FileTreeViewModel) CursorRight(filterRegex *regexp.Regexp) error { + node := vm.getAbsPositionNode(filterRegex) + if node == nil { + return nil + } + + if !node.Data.FileInfo.IsDir { + return nil + } + + if len(node.Children) == 0 { + return nil + } + + if node.Data.ViewInfo.Collapsed { + node.Data.ViewInfo.Collapsed = false + } + + vm.TreeIndex++ + if vm.TreeIndex > vm.bufferIndexUpperBound() { + vm.bufferIndexLowerBound++ + } + + vm.bufferIndex++ + if vm.bufferIndex > vm.height() { + vm.bufferIndex = vm.height() + } + + return nil +} + +// PageDown moves to next page putting the cursor on top +func (vm *FileTreeViewModel) PageDown() error { + nextBufferIndexLowerBound := vm.bufferIndexLowerBound + vm.height() + nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height() + + // todo: this work should be saved or passed to render... + treeString := vm.ViewTree.StringBetween(nextBufferIndexLowerBound, nextBufferIndexUpperBound, vm.ShowAttributes) + lines := strings.Split(treeString, "\n") + + newLines := len(lines) - 1 + if vm.height() >= newLines { + nextBufferIndexLowerBound = vm.bufferIndexLowerBound + newLines + } + + vm.bufferIndexLowerBound = nextBufferIndexLowerBound + + if vm.TreeIndex < nextBufferIndexLowerBound { + vm.bufferIndex = 0 + vm.TreeIndex = nextBufferIndexLowerBound + } else { + vm.bufferIndex = vm.bufferIndex - newLines + } + + return nil +} + +// PageUp moves to previous page putting the cursor on top +func (vm *FileTreeViewModel) PageUp() error { + nextBufferIndexLowerBound := vm.bufferIndexLowerBound - vm.height() + nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height() + + // todo: this work should be saved or passed to render... + treeString := vm.ViewTree.StringBetween(nextBufferIndexLowerBound, nextBufferIndexUpperBound, vm.ShowAttributes) + lines := strings.Split(treeString, "\n") + + newLines := len(lines) - 2 + if vm.height() >= newLines { + nextBufferIndexLowerBound = vm.bufferIndexLowerBound - newLines + } + + vm.bufferIndexLowerBound = nextBufferIndexLowerBound + + if vm.TreeIndex > (nextBufferIndexUpperBound - 1) { + vm.bufferIndex = 0 + vm.TreeIndex = nextBufferIndexLowerBound + } else { + vm.bufferIndex = vm.bufferIndex + newLines + } + return nil +} + +// getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode. +func (vm *FileTreeViewModel) getAbsPositionNode(filterRegex *regexp.Regexp) (node *filetree.FileNode) { + var visitor func(*filetree.FileNode) error + var evaluator func(*filetree.FileNode) bool + var dfsCounter int + + visitor = func(curNode *filetree.FileNode) error { + if dfsCounter == vm.TreeIndex { + node = curNode + } + dfsCounter++ + return nil + } + + evaluator = func(curNode *filetree.FileNode) bool { + regexMatch := true + if filterRegex != nil { + match := filterRegex.Find([]byte(curNode.Path())) + regexMatch = match != nil + } + return !curNode.Parent.Data.ViewInfo.Collapsed && !curNode.Data.ViewInfo.Hidden && regexMatch + } + + err := vm.ModelTree.VisitDepthParentFirst(visitor, evaluator) + if err != nil { + logrus.Errorf("unable to get node position: %+v", err) + } + + return node +} + +// toggleCollapse will collapse/expand the selected FileNode. +func (vm *FileTreeViewModel) toggleCollapse(filterRegex *regexp.Regexp) error { + node := vm.getAbsPositionNode(filterRegex) + if node != nil && node.Data.FileInfo.IsDir { + node.Data.ViewInfo.Collapsed = !node.Data.ViewInfo.Collapsed + } + return nil +} + +// toggleCollapseAll will collapse/expand the all directories. +func (vm *FileTreeViewModel) toggleCollapseAll() error { + vm.CollapseAll = !vm.CollapseAll + + visitor := func(curNode *filetree.FileNode) error { + curNode.Data.ViewInfo.Collapsed = vm.CollapseAll + return nil + } + + evaluator := func(curNode *filetree.FileNode) bool { + return curNode.Data.FileInfo.IsDir + } + + err := vm.ModelTree.VisitDepthChildFirst(visitor, evaluator) + if err != nil { + logrus.Errorf("unable to propagate tree on toggleCollapseAll: %+v", err) + } + + return nil +} + +// toggleCollapse will collapse/expand the selected FileNode. +func (vm *FileTreeViewModel) toggleAttributes() error { + vm.ShowAttributes = !vm.ShowAttributes + return nil +} + +// toggleShowDiffType will show/hide the selected DiffType in the filetree pane. +func (vm *FileTreeViewModel) toggleShowDiffType(diffType filetree.DiffType) error { + vm.HiddenDiffTypes[diffType] = !vm.HiddenDiffTypes[diffType] + + return nil +} + +// Update refreshes the state objects for future rendering. +func (vm *FileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height int) error { + vm.refWidth = width + vm.refHeight = height + + // keep the vm selection in parity with the current DiffType selection + err := vm.ModelTree.VisitDepthChildFirst(func(node *filetree.FileNode) error { + node.Data.ViewInfo.Hidden = vm.HiddenDiffTypes[node.Data.DiffType] + visibleChild := false + for _, child := range node.Children { + if !child.Data.ViewInfo.Hidden { + visibleChild = true + node.Data.ViewInfo.Hidden = false + } + } + // hide nodes that do not match the current file filter regex (also don't unhide nodes that are already hidden) + if filterRegex != nil && !visibleChild && !node.Data.ViewInfo.Hidden { + match := filterRegex.FindString(node.Path()) + node.Data.ViewInfo.Hidden = len(match) == 0 + } + return nil + }, nil) + + if err != nil { + logrus.Errorf("unable to propagate vm model tree: %+v", err) + return err + } + + // make a new tree with only visible nodes + vm.ViewTree = vm.ModelTree.Copy() + err = vm.ViewTree.VisitDepthParentFirst(func(node *filetree.FileNode) error { + if node.Data.ViewInfo.Hidden { + vm.ViewTree.RemovePath(node.Path()) + } + return nil + }, nil) + + if err != nil { + logrus.Errorf("unable to propagate vm view tree: %+v", err) + return err + } + + return nil +} + +// Render flushes the state objects (file tree) to the pane. +func (vm *FileTreeViewModel) Render() error { + treeString := vm.ViewTree.StringBetween(vm.bufferIndexLowerBound, vm.bufferIndexUpperBound(), vm.ShowAttributes) + lines := strings.Split(treeString, "\n") + + // update the contents + vm.mainBuf.Reset() + for idx, line := range lines { + if idx == vm.bufferIndex { + fmt.Fprintln(&vm.mainBuf, Formatting.Selected(vtclean.Clean(line, false))) + } else { + fmt.Fprintln(&vm.mainBuf, line) + } + } + return nil +} diff --git a/ui/filetree_viewmodel_test.go b/ui/filetree_viewmodel_test.go new file mode 100644 index 0000000..310c9eb --- /dev/null +++ b/ui/filetree_viewmodel_test.go @@ -0,0 +1,387 @@ +package ui + +import ( + "bytes" + "github.com/fatih/color" + "github.com/sergi/go-diff/diffmatchpatch" + "github.com/wagoodman/dive/filetree" + "github.com/wagoodman/dive/image" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "testing" +) + +const allowTestDataCapture = true + +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +func testCaseDataFilePath(name string) string { + return filepath.Join("testdata", name+".txt") +} + +func helperLoadBytes(t *testing.T) []byte { + path := testCaseDataFilePath(t.Name()) + theBytes, err := ioutil.ReadFile(path) + if err != nil { + t.Fatalf("unable to load test data ('%s'): %+v", t.Name(), err) + } + return theBytes +} + +func helperCaptureBytes(t *testing.T, data []byte) { + if !allowTestDataCapture { + t.Fatalf("cannot capture data in test mode: %s", t.Name()) + } + + path := testCaseDataFilePath(t.Name()) + err := ioutil.WriteFile(path, data, 0644) + + if err != nil { + t.Fatalf("unable to save test data ('%s'): %+v", t.Name(), err) + } +} + +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(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, err := image.TestLoadDockerImageTar("../.data/test-docker-image.tar") + if err != nil { + t.Fatalf("%s: unable to fetch analysis: %v", t.Name(), err) + } + cache := filetree.NewFileTreeCache(result.RefTrees) + cache.Build() + + Formatting.Selected = color.New(color.ReverseVideo, color.Bold).SprintFunc() + + return NewFileTreeViewModel(filetree.StackTreeRange(result.RefTrees, 0, 0), result.RefTrees, cache) +} + +func runTestCase(t *testing.T, vm *FileTreeViewModel, width, height int, filterRegex *regexp.Regexp) { + err := vm.Update(filterRegex, width, height) + if err != nil { + t.Errorf("failed to update viewmodel: %v", err) + } + + err = vm.Render() + if err != nil { + t.Errorf("failed to render viewmodel: %v", err) + } + + assertTestData(t, vm.mainBuf.Bytes()) +} + +func checkError(t *testing.T, err error, message string) { + if err != nil { + t.Errorf(message+": %+v", err) + } +} + +func TestFileTreeGoCase(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 1000 + vm.Setup(0, height) + vm.ShowAttributes = true + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreeNoAttributes(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 1000 + vm.Setup(0, height) + vm.ShowAttributes = false + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreeRestrictedHeight(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 20 + vm.Setup(0, height) + vm.ShowAttributes = false + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreeDirCollapse(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 100 + vm.Setup(0, height) + vm.ShowAttributes = true + + // collapse /bin + err := vm.toggleCollapse(nil) + checkError(t, err, "unable to collapse /bin") + + moved := vm.CursorDown() + if !moved { + t.Error("unable to cursor down") + } + + moved = vm.CursorDown() + if !moved { + t.Error("unable to cursor down") + } + + // collapse /etc + err = vm.toggleCollapse(nil) + checkError(t, err, "unable to collapse /etc") + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreeDirCollapseAll(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 100 + vm.Setup(0, height) + vm.ShowAttributes = true + + err := vm.toggleCollapseAll() + checkError(t, err, "unable to collapse all dir") + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreeSelectLayer(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 100 + vm.Setup(0, height) + vm.ShowAttributes = true + + // collapse /bin + err := vm.toggleCollapse(nil) + checkError(t, err, "unable to collapse /bin") + + // select the next layer, compareMode = layer + err = vm.setTreeByLayer(0, 0, 1, 1) + if err != nil { + t.Errorf("unable to setTreeByLayer: %v", err) + } + runTestCase(t, vm, width, height, nil) +} + +func TestFileShowAggregateChanges(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 100 + vm.Setup(0, height) + vm.ShowAttributes = true + + // collapse /bin + err := vm.toggleCollapse(nil) + checkError(t, err, "unable to collapse /bin") + + // select the next layer, compareMode = layer + err = vm.setTreeByLayer(0, 0, 1, 13) + checkError(t, err, "unable to setTreeByLayer") + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreePageDown(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 10 + vm.Setup(0, height) + vm.ShowAttributes = true + vm.Update(nil, width, height) + + err := vm.PageDown() + checkError(t, err, "unable to page down") + + err = vm.PageDown() + checkError(t, err, "unable to page down") + + err = vm.PageDown() + checkError(t, err, "unable to page down") + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreePageUp(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 10 + vm.Setup(0, height) + vm.ShowAttributes = true + + // these operations have a render step for intermediate results, which require at least one update to be done first + vm.Update(nil, width, height) + + err := vm.PageDown() + checkError(t, err, "unable to page down") + + err = vm.PageDown() + checkError(t, err, "unable to page down") + + err = vm.PageUp() + checkError(t, err, "unable to page up") + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreeDirCursorRight(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 100 + vm.Setup(0, height) + vm.ShowAttributes = true + + // collapse /bin + err := vm.toggleCollapse(nil) + checkError(t, err, "unable to collapse /bin") + + moved := vm.CursorDown() + if !moved { + t.Error("unable to cursor down") + } + + moved = vm.CursorDown() + if !moved { + t.Error("unable to cursor down") + } + + // collapse /etc + err = vm.toggleCollapse(nil) + checkError(t, err, "unable to collapse /etc") + + // expand /etc + err = vm.CursorRight(nil) + checkError(t, err, "unable to cursor right") + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreeFilterTree(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 1000 + vm.Setup(0, height) + vm.ShowAttributes = true + + regex, err := regexp.Compile("network") + if err != nil { + t.Errorf("could not create filter regex: %+v", err) + } + + runTestCase(t, vm, width, height, regex) +} + +func TestFileTreeHideAddedRemovedModified(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 100 + vm.Setup(0, height) + vm.ShowAttributes = true + + // collapse /bin + err := vm.toggleCollapse(nil) + checkError(t, err, "unable to collapse /bin") + + // select the 7th layer, compareMode = layer + err = vm.setTreeByLayer(0, 0, 1, 7) + if err != nil { + t.Errorf("unable to setTreeByLayer: %v", err) + } + + // hide added files + err = vm.toggleShowDiffType(filetree.Added) + checkError(t, err, "unable hide added files") + + // hide modified files + err = vm.toggleShowDiffType(filetree.Changed) + checkError(t, err, "unable hide added files") + + // hide removed files + err = vm.toggleShowDiffType(filetree.Removed) + checkError(t, err, "unable hide added files") + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreeHideUnmodified(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 100 + vm.Setup(0, height) + vm.ShowAttributes = true + + // collapse /bin + err := vm.toggleCollapse(nil) + checkError(t, err, "unable to collapse /bin") + + // select the 7th layer, compareMode = layer + err = vm.setTreeByLayer(0, 0, 1, 7) + if err != nil { + t.Errorf("unable to setTreeByLayer: %v", err) + } + + // hide unmodified files + err = vm.toggleShowDiffType(filetree.Unchanged) + checkError(t, err, "unable hide added files") + + runTestCase(t, vm, width, height, nil) +} + +func TestFileTreeHideTypeWithFilter(t *testing.T) { + vm := initializeTestViewModel(t) + + width, height := 100, 100 + vm.Setup(0, height) + vm.ShowAttributes = true + + // collapse /bin + err := vm.toggleCollapse(nil) + checkError(t, err, "unable to collapse /bin") + + // select the 7th layer, compareMode = layer + err = vm.setTreeByLayer(0, 0, 1, 7) + if err != nil { + t.Errorf("unable to setTreeByLayer: %v", err) + } + + // hide added files + err = vm.toggleShowDiffType(filetree.Added) + checkError(t, err, "unable hide added files") + + regex, err := regexp.Compile("saved") + if err != nil { + t.Errorf("could not create filter regex: %+v", err) + } + + runTestCase(t, vm, width, height, regex) +} diff --git a/ui/filetreeview.go b/ui/filetreeview.go deleted file mode 100644 index 56d40ae..0000000 --- a/ui/filetreeview.go +++ /dev/null @@ -1,634 +0,0 @@ -package ui - -import ( - "fmt" - "github.com/sirupsen/logrus" - "github.com/spf13/viper" - "github.com/wagoodman/dive/utils" - "github.com/wagoodman/keybinding" - "log" - "regexp" - "strings" - - "github.com/jroimartin/gocui" - "github.com/lunixbochs/vtclean" - "github.com/wagoodman/dive/filetree" -) - -const ( - CompareLayer CompareType = iota - CompareAll -) - -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 - view *gocui.View - header *gocui.View - ModelTree *filetree.FileTree - ViewTree *filetree.FileTree - RefTrees []*filetree.FileTree - cache filetree.TreeCache - HiddenDiffTypes []bool - TreeIndex uint - bufferIndex uint - bufferIndexUpperBound uint - bufferIndexLowerBound uint - - keybindingToggleCollapse []keybinding.Key - keybindingToggleCollapseAll []keybinding.Key - keybindingToggleAdded []keybinding.Key - keybindingToggleRemoved []keybinding.Key - keybindingToggleModified []keybinding.Key - keybindingToggleUnchanged []keybinding.Key - keybindingPageDown []keybinding.Key - keybindingPageUp []keybinding.Key -} - -// 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, cache filetree.TreeCache) (treeView *FileTreeView) { - treeView = new(FileTreeView) - - // populate main fields - treeView.Name = name - treeView.gui = gui - treeView.ModelTree = tree - treeView.RefTrees = refTrees - treeView.cache = cache - treeView.HiddenDiffTypes = make([]bool, 4) - - hiddenTypes := viper.GetStringSlice("diff.hide") - for _, hType := range hiddenTypes { - switch t := strings.ToLower(hType); t { - case "added": - treeView.HiddenDiffTypes[filetree.Added] = true - case "removed": - treeView.HiddenDiffTypes[filetree.Removed] = true - case "changed": - treeView.HiddenDiffTypes[filetree.Changed] = true - case "unchanged": - treeView.HiddenDiffTypes[filetree.Unchanged] = true - default: - utils.PrintAndExit(fmt.Sprintf("unknown diff.hide value: %s", t)) - } - } - - var err error - treeView.keybindingToggleCollapse, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-collapse-dir")) - if err != nil { - log.Panicln(err) - } - - treeView.keybindingToggleCollapseAll, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-collapse-all-dir")) - if err != nil { - log.Panicln(err) - } - - treeView.keybindingToggleAdded, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-added-files")) - if err != nil { - log.Panicln(err) - } - - treeView.keybindingToggleRemoved, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-removed-files")) - if err != nil { - log.Panicln(err) - } - - treeView.keybindingToggleModified, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-modified-files")) - if err != nil { - log.Panicln(err) - } - - treeView.keybindingToggleUnchanged, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-unchanged-files")) - if err != nil { - log.Panicln(err) - } - - treeView.keybindingPageUp, err = keybinding.ParseAll(viper.GetString("keybinding.page-up")) - if err != nil { - log.Panicln(err) - } - - treeView.keybindingPageDown, err = keybinding.ParseAll(viper.GetString("keybinding.page-down")) - if err != nil { - log.Panicln(err) - } - - 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 - view.view = v - view.view.Editable = false - view.view.Wrap = 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 - } - if err := view.gui.SetKeybinding(view.Name, gocui.KeyArrowLeft, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return view.CursorLeft() }); err != nil { - return err - } - if err := view.gui.SetKeybinding(view.Name, gocui.KeyArrowRight, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return view.CursorRight() }); err != nil { - return err - } - - for _, key := range view.keybindingPageUp { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.PageUp() }); err != nil { - return err - } - } - for _, key := range view.keybindingPageDown { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.PageDown() }); err != nil { - return err - } - } - for _, key := range view.keybindingToggleCollapse { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.toggleCollapse() }); err != nil { - return err - } - } - for _, key := range view.keybindingToggleCollapseAll { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.toggleCollapseAll() }); err != nil { - return err - } - } - for _, key := range view.keybindingToggleAdded { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.toggleShowDiffType(filetree.Added) }); err != nil { - return err - } - } - for _, key := range view.keybindingToggleRemoved { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.toggleShowDiffType(filetree.Removed) }); err != nil { - return err - } - } - for _, key := range view.keybindingToggleModified { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.toggleShowDiffType(filetree.Changed) }); err != nil { - return err - } - } - for _, key := range view.keybindingToggleUnchanged { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.toggleShowDiffType(filetree.Unchanged) }); err != nil { - return err - } - } - - view.bufferIndexLowerBound = 0 - view.bufferIndexUpperBound = view.height() // don't include the header or footer in the view size - - view.Update() - view.Render() - - 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) - } - newTree := view.cache.Get(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) - - // preserve view state on copy - visitor := func(node *filetree.FileNode) error { - newNode, err := newTree.GetNode(node.Path()) - if err == nil { - newNode.Data.ViewInfo = node.Data.ViewInfo - } - return nil - } - err := view.ModelTree.VisitDepthChildFirst(visitor, nil) - if err != nil { - logrus.Errorf("unable to propagate layer tree: %+v", err) - } - - 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 { - view.bufferIndexUpperBound-- - view.bufferIndexLowerBound-- - } - - if view.bufferIndex > 0 { - view.bufferIndex-- - } -} - -// 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 { - view.bufferIndexUpperBound++ - view.bufferIndexLowerBound++ - } - view.bufferIndex++ - if view.bufferIndex > view.height() { - view.bufferIndex = view.height() - } -} - -// 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 { - 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 { - if view.TreeIndex > 0 { - view.doCursorUp() - return view.Render() - } - return nil -} - -// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree -func (view *FileTreeView) CursorLeft() error { - var visitor func(*filetree.FileNode) error - var evaluator func(*filetree.FileNode) bool - var dfsCounter, newIndex uint - oldIndex := view.TreeIndex - currentNode := view.getAbsPositionNode() - if currentNode == nil { - return nil - } - parentPath := currentNode.Parent.Path() - - visitor = func(curNode *filetree.FileNode) error { - if strings.Compare(parentPath, curNode.Path()) == 0 { - newIndex = dfsCounter - } - dfsCounter++ - return nil - } - var filterBytes []byte - var filterRegex *regexp.Regexp - read, err := Views.Filter.view.Read(filterBytes) - if read > 0 && err == nil { - regex, err := regexp.Compile(string(filterBytes)) - if err == nil { - filterRegex = regex - } - } - - evaluator = func(curNode *filetree.FileNode) bool { - regexMatch := true - if filterRegex != nil { - match := filterRegex.Find([]byte(curNode.Path())) - regexMatch = match != nil - } - return !curNode.Parent.Data.ViewInfo.Collapsed && !curNode.Data.ViewInfo.Hidden && regexMatch - } - - err = view.ModelTree.VisitDepthParentFirst(visitor, evaluator) - if err != nil { - logrus.Panic(err) - } - - view.TreeIndex = newIndex - moveIndex := oldIndex - newIndex - if newIndex < view.bufferIndexLowerBound { - view.bufferIndexUpperBound = view.TreeIndex + view.height() - view.bufferIndexLowerBound = view.TreeIndex - } - - if view.bufferIndex > moveIndex { - view.bufferIndex = view.bufferIndex - moveIndex - } else { - view.bufferIndex = 0 - } - - view.Update() - return view.Render() -} - -// CursorRight descends into directory expanding it if needed -func (view *FileTreeView) CursorRight() error { - node := view.getAbsPositionNode() - if node == nil { - return nil - } - if !node.Data.FileInfo.IsDir { - return nil - } - if len(node.Children) == 0 { - return nil - } - if node.Data.ViewInfo.Collapsed { - node.Data.ViewInfo.Collapsed = false - } - view.TreeIndex++ - if view.TreeIndex > view.bufferIndexUpperBound { - view.bufferIndexUpperBound++ - view.bufferIndexLowerBound++ - } - view.bufferIndex++ - if view.bufferIndex > view.height() { - view.bufferIndex = view.height() - } - view.Update() - return view.Render() -} - -// PageDown moves to next page putting the cursor on top -func (view *FileTreeView) PageDown() error { - nextBufferIndexLowerBound := view.bufferIndexLowerBound + view.height() - nextBufferIndexUpperBound := view.bufferIndexUpperBound + view.height() - - treeString := view.ViewTree.StringBetween(nextBufferIndexLowerBound, nextBufferIndexUpperBound, true) - lines := strings.Split(treeString, "\n") - - newLines := uint(len(lines)) - 1 - if view.height() >= newLines { - nextBufferIndexLowerBound = view.bufferIndexLowerBound + newLines - nextBufferIndexUpperBound = view.bufferIndexUpperBound + newLines - } - view.bufferIndexLowerBound = nextBufferIndexLowerBound - view.bufferIndexUpperBound = nextBufferIndexUpperBound - - if view.TreeIndex < nextBufferIndexLowerBound { - view.bufferIndex = 0 - view.TreeIndex = nextBufferIndexLowerBound - } else { - view.bufferIndex = view.bufferIndex - newLines - } - return view.Render() -} - -// PageUp moves to previous page putting the cursor on top -func (view *FileTreeView) PageUp() error { - nextBufferIndexLowerBound := view.bufferIndexLowerBound - view.height() - nextBufferIndexUpperBound := view.bufferIndexUpperBound - view.height() - - treeString := view.ViewTree.StringBetween(nextBufferIndexLowerBound, nextBufferIndexUpperBound, true) - lines := strings.Split(treeString, "\n") - - newLines := uint(len(lines)) - 2 - if view.height() >= newLines { - nextBufferIndexLowerBound = view.bufferIndexLowerBound - newLines - nextBufferIndexUpperBound = view.bufferIndexUpperBound - newLines - } - view.bufferIndexLowerBound = nextBufferIndexLowerBound - view.bufferIndexUpperBound = nextBufferIndexUpperBound - - if view.TreeIndex > (nextBufferIndexUpperBound - 1) { - view.bufferIndex = 0 - view.TreeIndex = nextBufferIndexLowerBound - } else { - view.bufferIndex = view.bufferIndex + newLines - } - return view.Render() -} - -// getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode. -func (view *FileTreeView) getAbsPositionNode() (node *filetree.FileNode) { - var visitor func(*filetree.FileNode) error - var evaluator func(*filetree.FileNode) bool - var dfsCounter uint - - visitor = func(curNode *filetree.FileNode) error { - if dfsCounter == view.TreeIndex { - node = curNode - } - dfsCounter++ - return nil - } - var filterBytes []byte - var filterRegex *regexp.Regexp - read, err := Views.Filter.view.Read(filterBytes) - if read > 0 && err == nil { - regex, err := regexp.Compile(string(filterBytes)) - if err == nil { - filterRegex = regex - } - } - - evaluator = func(curNode *filetree.FileNode) bool { - regexMatch := true - if filterRegex != nil { - match := filterRegex.Find([]byte(curNode.Path())) - regexMatch = match != nil - } - return !curNode.Parent.Data.ViewInfo.Collapsed && !curNode.Data.ViewInfo.Hidden && regexMatch - } - - err = view.ModelTree.VisitDepthParentFirst(visitor, evaluator) - if err != nil { - logrus.Panic(err) - } - - return node -} - -// toggleCollapse will collapse/expand the selected FileNode. -func (view *FileTreeView) toggleCollapse() error { - node := view.getAbsPositionNode() - if node != nil && node.Data.FileInfo.IsDir { - node.Data.ViewInfo.Collapsed = !node.Data.ViewInfo.Collapsed - } - view.Update() - return view.Render() -} - -// toggleCollapseAll will collapse/expand the all directories. -func (view *FileTreeView) toggleCollapseAll() error { - node := view.getAbsPositionNode() - var collapseTargetState bool - if node != nil && node.Data.FileInfo.IsDir { - collapseTargetState = !node.Data.ViewInfo.Collapsed - } - - visitor := func(curNode *filetree.FileNode) error { - curNode.Data.ViewInfo.Collapsed = collapseTargetState - return nil - } - - evaluator := func(curNode *filetree.FileNode) bool { - return curNode.Data.FileInfo.IsDir - } - - err := view.ModelTree.VisitDepthChildFirst(visitor, evaluator) - if err != nil { - logrus.Panic(err) - } - - view.Update() - 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.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 - } - filterString := strings.TrimSpace(Views.Filter.view.Buffer()) - if len(filterString) == 0 { - return nil - } - - regex, err := regexp.Compile(filterString) - if err != nil { - return nil - } - - return regex -} - -// Update refreshes the state objects for future rendering. -func (view *FileTreeView) Update() error { - regex := filterRegex() - - // keep the view selection in parity with the current DiffType selection - err := view.ModelTree.VisitDepthChildFirst(func(node *filetree.FileNode) error { - node.Data.ViewInfo.Hidden = view.HiddenDiffTypes[node.Data.DiffType] - visibleChild := false - for _, child := range node.Children { - if !child.Data.ViewInfo.Hidden { - visibleChild = true - node.Data.ViewInfo.Hidden = false - } - } - if regex != nil && !visibleChild { - match := regex.FindString(node.Path()) - node.Data.ViewInfo.Hidden = len(match) == 0 - } - return nil - }, nil) - - if err != nil { - logrus.Errorf("unable to propagate model tree: %+v", err) - } - - // make a new tree with only visible nodes - view.ViewTree = view.ModelTree.Copy() - err = view.ViewTree.VisitDepthParentFirst(func(node *filetree.FileNode) error { - if node.Data.ViewInfo.Hidden { - view.ViewTree.RemovePath(node.Path()) - } - return nil - }, nil) - - if err != nil { - logrus.Errorf("unable to propagate view tree: %+v", err) - } - - return nil -} - -// 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") - - // undo a cursor down that has gone past bottom of the visible tree - if view.bufferIndex >= uint(len(lines))-1 { - 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() - 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 - view.view.Clear() - for idx, line := range lines { - if uint(idx) == view.bufferIndex { - fmt.Fprintln(view.view, Formatting.Selected(vtclean.Clean(line, false))) - } else { - fmt.Fprintln(view.view, line) - } - } - // todo: should we check error on the view println? - return nil - }) - 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(view.keybindingToggleCollapse[0].String(), "Collapse dir", false) + - renderStatusOption(view.keybindingToggleCollapseAll[0].String(), "Collapse all dir", false) + - renderStatusOption(view.keybindingToggleAdded[0].String(), "Added", !view.HiddenDiffTypes[filetree.Added]) + - renderStatusOption(view.keybindingToggleRemoved[0].String(), "Removed", !view.HiddenDiffTypes[filetree.Removed]) + - renderStatusOption(view.keybindingToggleModified[0].String(), "Modified", !view.HiddenDiffTypes[filetree.Changed]) + - renderStatusOption(view.keybindingToggleUnchanged[0].String(), "Unmodified", !view.HiddenDiffTypes[filetree.Unchanged]) -} diff --git a/ui/filter_controller.go b/ui/filter_controller.go new file mode 100644 index 0000000..0bc8bf8 --- /dev/null +++ b/ui/filter_controller.go @@ -0,0 +1,116 @@ +package ui + +import ( + "fmt" + + "github.com/jroimartin/gocui" +) + +// FilterController 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 FilterController struct { + Name string + gui *gocui.Gui + view *gocui.View + header *gocui.View + headerStr string + maxLength int + hidden bool +} + +// NewFilterController creates a new view object attached the the global [gocui] screen object. +func NewFilterController(name string, gui *gocui.Gui) (controller *FilterController) { + controller = new(FilterController) + + // populate main fields + controller.Name = name + controller.gui = gui + controller.headerStr = "Path Filter: " + controller.hidden = true + + return controller +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (controller *FilterController) Setup(v *gocui.View, header *gocui.View) error { + + // set controller options + controller.view = v + controller.maxLength = 200 + controller.view.Frame = false + controller.view.BgColor = gocui.AttrReverse + controller.view.Editable = true + controller.view.Editor = controller + + controller.header = header + controller.header.BgColor = gocui.AttrReverse + controller.header.Editable = false + controller.header.Wrap = false + controller.header.Frame = false + + controller.Render() + + return nil +} + +// IsVisible indicates if the filter view pane is currently initialized +func (controller *FilterController) IsVisible() bool { + if controller == nil { + return false + } + return !controller.hidden +} + +// CursorDown moves the cursor down in the filter pane (currently indicates nothing). +func (controller *FilterController) CursorDown() error { + return nil +} + +// CursorUp moves the cursor up in the filter pane (currently indicates nothing). +func (controller *FilterController) CursorUp() error { + return nil +} + +// Edit intercepts the key press events in the filer view to update the file view in real time. +func (controller *FilterController) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { + if !controller.IsVisible() { + return + } + + cx, _ := v.Cursor() + ox, _ := v.Origin() + limit := ox+cx+1 > controller.maxLength + switch { + case ch != 0 && mod == 0 && !limit: + v.EditWrite(ch) + case key == gocui.KeySpace && !limit: + v.EditWrite(' ') + case key == gocui.KeyBackspace || key == gocui.KeyBackspace2: + v.EditDelete(true) + } + if Controllers.Tree != nil { + Controllers.Tree.Update() + Controllers.Tree.Render() + } +} + +// Update refreshes the state objects for future rendering (currently does nothing). +func (controller *FilterController) Update() error { + return nil +} + +// Render flushes the state objects to the screen. Currently this is the users path filter input. +func (controller *FilterController) Render() error { + controller.gui.Update(func(g *gocui.Gui) error { + // render the header + fmt.Fprintln(controller.header, Formatting.Header(controller.headerStr)) + + return nil + }) + return nil +} + +// KeyHelp indicates all the possible actions a user can take while the current pane is selected. +func (controller *FilterController) KeyHelp() string { + return Formatting.StatusControlNormal("▏Type to filter the file tree ") +} diff --git a/ui/filterview.go b/ui/filterview.go deleted file mode 100644 index 739b0ce..0000000 --- a/ui/filterview.go +++ /dev/null @@ -1,116 +0,0 @@ -package ui - -import ( - "fmt" - - "github.com/jroimartin/gocui" -) - -// 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 - view *gocui.View - header *gocui.View - headerStr string - maxLength int - hidden bool -} - -// 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 - - 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 - view.view = v - view.maxLength = 200 - view.view.Frame = false - view.view.BgColor = gocui.AttrReverse - view.view.Editable = true - view.view.Editor = view - - view.header = header - view.header.BgColor = gocui.AttrReverse - view.header.Editable = false - view.header.Wrap = false - view.header.Frame = false - - 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 - } - - cx, _ := v.Cursor() - ox, _ := v.Origin() - limit := ox+cx+1 > view.maxLength - switch { - case ch != 0 && mod == 0 && !limit: - v.EditWrite(ch) - case key == gocui.KeySpace && !limit: - v.EditWrite(' ') - case key == gocui.KeyBackspace || key == gocui.KeyBackspace2: - v.EditDelete(true) - } - if Views.Tree != nil { - Views.Tree.Update() - Views.Tree.Render() - } -} - -// 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 - fmt.Fprintln(view.header, Formatting.Header(view.headerStr)) - - return nil - }) - 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 ") -} diff --git a/ui/layer_controller.go b/ui/layer_controller.go new file mode 100644 index 0000000..0210544 --- /dev/null +++ b/ui/layer_controller.go @@ -0,0 +1,315 @@ +package ui + +import ( + "fmt" + "github.com/jroimartin/gocui" + "github.com/lunixbochs/vtclean" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/wagoodman/dive/image" + "github.com/wagoodman/dive/utils" + "github.com/wagoodman/keybinding" + "strings" +) + +// LayerController 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 LayerController struct { + Name string + gui *gocui.Gui + view *gocui.View + header *gocui.View + LayerIndex int + Layers []image.Layer + CompareMode CompareType + CompareStartIndex int + ImageSize uint64 + + keybindingCompareAll []keybinding.Key + keybindingCompareLayer []keybinding.Key + keybindingPageDown []keybinding.Key + keybindingPageUp []keybinding.Key +} + +// NewLayerController creates a new view object attached the the global [gocui] screen object. +func NewLayerController(name string, gui *gocui.Gui, layers []image.Layer) (controller *LayerController) { + controller = new(LayerController) + + // populate main fields + controller.Name = name + controller.gui = gui + controller.Layers = layers + + switch mode := viper.GetBool("layer.show-aggregated-changes"); mode { + case true: + controller.CompareMode = CompareAll + case false: + controller.CompareMode = CompareLayer + default: + utils.PrintAndExit(fmt.Sprintf("unknown layer.show-aggregated-changes value: %v", mode)) + } + + var err error + controller.keybindingCompareAll, err = keybinding.ParseAll(viper.GetString("keybinding.compare-all")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingCompareLayer, err = keybinding.ParseAll(viper.GetString("keybinding.compare-layer")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingPageUp, err = keybinding.ParseAll(viper.GetString("keybinding.page-up")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingPageDown, err = keybinding.ParseAll(viper.GetString("keybinding.page-down")) + if err != nil { + logrus.Error(err) + } + + return controller +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (controller *LayerController) Setup(v *gocui.View, header *gocui.View) error { + + // set controller options + controller.view = v + controller.view.Editable = false + controller.view.Wrap = false + controller.view.Frame = false + + controller.header = header + controller.header.Editable = false + controller.header.Wrap = false + controller.header.Frame = false + + // set keybindings + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorDown() }); err != nil { + return err + } + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowUp, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorUp() }); err != nil { + return err + } + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowRight, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorDown() }); err != nil { + return err + } + if err := controller.gui.SetKeybinding(controller.Name, gocui.KeyArrowLeft, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { return controller.CursorUp() }); err != nil { + return err + } + + for _, key := range controller.keybindingPageUp { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.PageUp() }); err != nil { + return err + } + } + for _, key := range controller.keybindingPageDown { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.PageDown() }); err != nil { + return err + } + } + + for _, key := range controller.keybindingCompareLayer { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.setCompareMode(CompareLayer) }); err != nil { + return err + } + } + + for _, key := range controller.keybindingCompareAll { + if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.setCompareMode(CompareAll) }); err != nil { + return err + } + } + + return controller.Render() +} + +// height obtains the height of the current pane (taking into account the lost space due to the header). +func (controller *LayerController) height() uint { + _, height := controller.view.Size() + return uint(height - 1) +} + +// IsVisible indicates if the layer view pane is currently initialized. +func (controller *LayerController) IsVisible() bool { + if controller == nil { + return false + } + return true +} + +// PageDown moves to next page putting the cursor on top +func (controller *LayerController) PageDown() error { + step := int(controller.height()) + 1 + targetLayerIndex := controller.LayerIndex + step + + if targetLayerIndex > len(controller.Layers) { + step -= targetLayerIndex - (len(controller.Layers) - 1) + targetLayerIndex = controller.LayerIndex + step + } + + if step > 0 { + err := CursorStep(controller.gui, controller.view, step) + if err == nil { + controller.SetCursor(controller.LayerIndex + step) + } + } + return nil +} + +// PageUp moves to previous page putting the cursor on top +func (controller *LayerController) PageUp() error { + step := int(controller.height()) + 1 + targetLayerIndex := controller.LayerIndex - step + + if targetLayerIndex < 0 { + step += targetLayerIndex + targetLayerIndex = controller.LayerIndex - step + } + + if step > 0 { + err := CursorStep(controller.gui, controller.view, -step) + if err == nil { + controller.SetCursor(controller.LayerIndex - step) + } + } + return nil +} + +// CursorDown moves the cursor down in the layer pane (selecting a higher layer). +func (controller *LayerController) CursorDown() error { + if controller.LayerIndex < len(controller.Layers) { + err := CursorDown(controller.gui, controller.view) + if err == nil { + controller.SetCursor(controller.LayerIndex + 1) + } + } + return nil +} + +// CursorUp moves the cursor up in the layer pane (selecting a lower layer). +func (controller *LayerController) CursorUp() error { + if controller.LayerIndex > 0 { + err := CursorUp(controller.gui, controller.view) + if err == nil { + controller.SetCursor(controller.LayerIndex - 1) + } + } + return nil +} + +// SetCursor resets the cursor and orients the file tree view based on the given layer index. +func (controller *LayerController) SetCursor(layer int) error { + controller.LayerIndex = layer + Controllers.Tree.setTreeByLayer(controller.getCompareIndexes()) + Controllers.Details.Render() + controller.Render() + + return nil +} + +// currentLayer returns the Layer object currently selected. +func (controller *LayerController) currentLayer() image.Layer { + return controller.Layers[(len(controller.Layers)-1)-controller.LayerIndex] +} + +// setCompareMode switches the layer comparison between a single-layer comparison to an aggregated comparison. +func (controller *LayerController) setCompareMode(compareMode CompareType) error { + controller.CompareMode = compareMode + Update() + Render() + return Controllers.Tree.setTreeByLayer(controller.getCompareIndexes()) +} + +// getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode) +func (controller *LayerController) getCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) { + bottomTreeStart = controller.CompareStartIndex + topTreeStop = controller.LayerIndex + + if controller.LayerIndex == controller.CompareStartIndex { + bottomTreeStop = controller.LayerIndex + topTreeStart = controller.LayerIndex + } else if controller.CompareMode == CompareLayer { + bottomTreeStop = controller.LayerIndex - 1 + topTreeStart = controller.LayerIndex + } else { + bottomTreeStop = controller.CompareStartIndex + topTreeStart = controller.CompareStartIndex + 1 + } + + return bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop +} + +// renderCompareBar returns the formatted string for the given layer. +func (controller *LayerController) renderCompareBar(layerIdx int) string { + bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := controller.getCompareIndexes() + result := " " + + if layerIdx >= bottomTreeStart && layerIdx <= bottomTreeStop { + result = Formatting.CompareBottom(" ") + } + if layerIdx >= topTreeStart && layerIdx <= topTreeStop { + result = Formatting.CompareTop(" ") + } + + return result +} + +// Update refreshes the state objects for future rendering (currently does nothing). +func (controller *LayerController) Update() error { + controller.ImageSize = 0 + for idx := 0; idx < len(controller.Layers); idx++ { + controller.ImageSize += controller.Layers[idx].Size() + } + 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 (controller *LayerController) Render() error { + + // indicate when selected + title := "Layers" + if controller.gui.CurrentView() == controller.view { + title = "● " + title + } + + controller.gui.Update(func(g *gocui.Gui) error { + // update header + controller.header.Clear() + width, _ := g.Size() + headerStr := fmt.Sprintf("[%s]%s\n", title, strings.Repeat("─", width*2)) + // headerStr += fmt.Sprintf("Cmp "+image.LayerFormat, "Layer Digest", "Size", "Command") + headerStr += fmt.Sprintf("Cmp"+image.LayerFormat, "Size", "Command") + fmt.Fprintln(controller.header, Formatting.Header(vtclean.Clean(headerStr, false))) + + // update contents + controller.view.Clear() + for revIdx := len(controller.Layers) - 1; revIdx >= 0; revIdx-- { + layer := controller.Layers[revIdx] + idx := (len(controller.Layers) - 1) - revIdx + + layerStr := layer.String() + compareBar := controller.renderCompareBar(idx) + + if idx == controller.LayerIndex { + fmt.Fprintln(controller.view, compareBar+" "+Formatting.Selected(layerStr)) + } else { + fmt.Fprintln(controller.view, compareBar+" "+layerStr) + } + + } + return nil + }) + return nil +} + +// KeyHelp indicates all the possible actions a user can take while the current pane is selected. +func (controller *LayerController) KeyHelp() string { + return renderStatusOption(controller.keybindingCompareLayer[0].String(), "Show layer changes", controller.CompareMode == CompareLayer) + + renderStatusOption(controller.keybindingCompareAll[0].String(), "Show aggregated changes", controller.CompareMode == CompareAll) +} diff --git a/ui/layerview.go b/ui/layerview.go deleted file mode 100644 index dcf5617..0000000 --- a/ui/layerview.go +++ /dev/null @@ -1,309 +0,0 @@ -package ui - -import ( - "fmt" - "github.com/spf13/viper" - "github.com/wagoodman/dive/utils" - "github.com/wagoodman/keybinding" - "log" - - "github.com/jroimartin/gocui" - "github.com/lunixbochs/vtclean" - "github.com/wagoodman/dive/image" - "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 - view *gocui.View - header *gocui.View - LayerIndex int - Layers []image.Layer - CompareMode CompareType - CompareStartIndex int - ImageSize uint64 - - keybindingCompareAll []keybinding.Key - keybindingCompareLayer []keybinding.Key - keybindingPageDown []keybinding.Key - keybindingPageUp []keybinding.Key -} - -// 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 - - switch mode := viper.GetBool("layer.show-aggregated-changes"); mode { - case true: - layerView.CompareMode = CompareAll - case false: - layerView.CompareMode = CompareLayer - default: - utils.PrintAndExit(fmt.Sprintf("unknown layer.show-aggregated-changes value: %v", mode)) - } - - var err error - layerView.keybindingCompareAll, err = keybinding.ParseAll(viper.GetString("keybinding.compare-all")) - if err != nil { - log.Panicln(err) - } - - layerView.keybindingCompareLayer, err = keybinding.ParseAll(viper.GetString("keybinding.compare-layer")) - if err != nil { - log.Panicln(err) - } - - layerView.keybindingPageUp, err = keybinding.ParseAll(viper.GetString("keybinding.page-up")) - if err != nil { - log.Panicln(err) - } - - layerView.keybindingPageDown, err = keybinding.ParseAll(viper.GetString("keybinding.page-down")) - if err != nil { - log.Panicln(err) - } - - 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.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 - } - - for _, key := range view.keybindingPageUp { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.PageUp() }); err != nil { - return err - } - } - for _, key := range view.keybindingPageDown { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.PageDown() }); err != nil { - return err - } - } - - for _, key := range view.keybindingCompareLayer { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.setCompareMode(CompareLayer) }); err != nil { - return err - } - } - - for _, key := range view.keybindingCompareAll { - if err := view.gui.SetKeybinding(view.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return view.setCompareMode(CompareAll) }); err != nil { - return err - } - } - - return view.Render() -} - -// height obtains the height of the current pane (taking into account the lost space due to the header). -func (view *LayerView) height() uint { - _, height := view.view.Size() - return uint(height - 1) -} - -// IsVisible indicates if the layer view pane is currently initialized. -func (view *LayerView) IsVisible() bool { - if view == nil { - return false - } - return true -} - -// PageDown moves to next page putting the cursor on top -func (view *LayerView) PageDown() error { - step := int(view.height()) + 1 - targetLayerIndex := view.LayerIndex + step - - if targetLayerIndex > len(view.Layers) { - step -= targetLayerIndex - (len(view.Layers) - 1) - targetLayerIndex = view.LayerIndex + step - } - - if step > 0 { - err := CursorStep(view.gui, view.view, step) - if err == nil { - view.SetCursor(view.LayerIndex + step) - } - } - return nil -} - -// PageUp moves to previous page putting the cursor on top -func (view *LayerView) PageUp() error { - step := int(view.height()) + 1 - targetLayerIndex := view.LayerIndex - step - - if targetLayerIndex < 0 { - step += targetLayerIndex - targetLayerIndex = view.LayerIndex - step - } - - if step > 0 { - err := CursorStep(view.gui, view.view, -step) - if err == nil { - view.SetCursor(view.LayerIndex - step) - } - } - return nil -} - -// 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() - Render() - 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 - - if view.LayerIndex == view.CompareStartIndex { - bottomTreeStop = view.LayerIndex - topTreeStart = view.LayerIndex - } else if view.CompareMode == CompareLayer { - bottomTreeStop = view.LayerIndex - 1 - topTreeStart = view.LayerIndex - } else { - bottomTreeStop = view.CompareStartIndex - topTreeStart = view.CompareStartIndex + 1 - } - - 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 layerIdx >= bottomTreeStart && layerIdx <= bottomTreeStop { - result = Formatting.CompareBottom(" ") - } - if layerIdx >= topTreeStart && layerIdx <= topTreeStop { - result = Formatting.CompareTop(" ") - } - - return result -} - -// Update refreshes the state objects for future rendering (currently does nothing). -func (view *LayerView) Update() error { - view.ImageSize = 0 - for idx := 0; idx < len(view.Layers); idx++ { - view.ImageSize += view.Layers[idx].Size() - } - 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 - 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 - view.view.Clear() - for revIdx := len(view.Layers) - 1; revIdx >= 0; revIdx-- { - layer := view.Layers[revIdx] - idx := (len(view.Layers) - 1) - revIdx - - layerStr := layer.String() - compareBar := view.renderCompareBar(idx) - - if idx == view.LayerIndex { - fmt.Fprintln(view.view, compareBar+" "+Formatting.Selected(layerStr)) - } else { - fmt.Fprintln(view.view, compareBar+" "+layerStr) - } - - } - return nil - }) - 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(view.keybindingCompareLayer[0].String(), "Show layer changes", view.CompareMode == CompareLayer) + - renderStatusOption(view.keybindingCompareAll[0].String(), "Show aggregated changes", view.CompareMode == CompareAll) -} diff --git a/ui/status_controller.go b/ui/status_controller.go new file mode 100644 index 0000000..da4f660 --- /dev/null +++ b/ui/status_controller.go @@ -0,0 +1,81 @@ +package ui + +import ( + "fmt" + + "github.com/jroimartin/gocui" + "strings" +) + +// StatusController 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 StatusController struct { + Name string + gui *gocui.Gui + view *gocui.View +} + +// NewStatusController creates a new view object attached the the global [gocui] screen object. +func NewStatusController(name string, gui *gocui.Gui) (controller *StatusController) { + controller = new(StatusController) + + // populate main fields + controller.Name = name + controller.gui = gui + + return controller +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (controller *StatusController) Setup(v *gocui.View, header *gocui.View) error { + + // set controller options + controller.view = v + controller.view.Frame = false + + controller.Render() + + return nil +} + +// IsVisible indicates if the status view pane is currently initialized. +func (controller *StatusController) IsVisible() bool { + if controller == nil { + return false + } + return true +} + +// CursorDown moves the cursor down in the details pane (currently indicates nothing). +func (controller *StatusController) CursorDown() error { + return nil +} + +// CursorUp moves the cursor up in the details pane (currently indicates nothing). +func (controller *StatusController) CursorUp() error { + return nil +} + +// Update refreshes the state objects for future rendering (currently does nothing). +func (controller *StatusController) Update() error { + return nil +} + +// Render flushes the state objects to the screen. +func (controller *StatusController) Render() error { + controller.gui.Update(func(g *gocui.Gui) error { + controller.view.Clear() + fmt.Fprintln(controller.view, controller.KeyHelp()+Controllers.lookup[controller.gui.CurrentView().Name()].KeyHelp()+Formatting.StatusNormal("▏"+strings.Repeat(" ", 1000))) + + return nil + }) + // todo: blerg + return nil +} + +// KeyHelp indicates all the possible global actions a user can take when any pane is selected. +func (controller *StatusController) KeyHelp() string { + return renderStatusOption(GlobalKeybindings.quit[0].String(), "Quit", false) + + renderStatusOption(GlobalKeybindings.toggleView[0].String(), "Switch view", false) + + renderStatusOption(GlobalKeybindings.filterView[0].String(), "Filter", Controllers.Filter.IsVisible()) +} diff --git a/ui/statusview.go b/ui/statusview.go deleted file mode 100644 index 266077c..0000000 --- a/ui/statusview.go +++ /dev/null @@ -1,81 +0,0 @@ -package ui - -import ( - "fmt" - - "github.com/jroimartin/gocui" - "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 -} - -// 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 - - 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.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 -} - -// 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() - fmt.Fprintln(view.view, view.KeyHelp()+Views.lookup[view.gui.CurrentView().Name()].KeyHelp()+Formatting.StatusNormal("▏"+strings.Repeat(" ", 1000))) - - return nil - }) - // 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(GlobalKeybindings.quit[0].String(), "Quit", false) + - renderStatusOption(GlobalKeybindings.toggleView[0].String(), "Switch view", false) + - renderStatusOption(GlobalKeybindings.filterView[0].String(), "Filter", Views.Filter.IsVisible()) -} diff --git a/ui/testdata/TestFileShowAggregateChanges.txt b/ui/testdata/TestFileShowAggregateChanges.txt new file mode 100644 index 0000000..5fdc49c --- /dev/null +++ b/ui/testdata/TestFileShowAggregateChanges.txt @@ -0,0 +1,36 @@ +drwxr-xr-x 0:0 1.2 MB ├─⊕ bin +drwxr-xr-x 0:0 0 B ├── dev +drwxr-xr-x 0:0 1.0 kB ├── etc +-rw-rw-r-- 0:0 307 B │ ├── group +-rw-r--r-- 0:0 127 B │ ├── localtime +drwxr-xr-x 0:0 0 B │ ├── network +drwxr-xr-x 0:0 0 B │ │ ├── if-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d +drwxr-xr-x 0:0 0 B │ │ └── if-up.d +-rw-r--r-- 0:0 340 B │ ├── passwd +-rw------- 0:0 243 B │ └── shadow +drwxr-xr-x 65534:65534 0 B ├── home +drwx------ 0:0 21 kB ├── root +drwxr-xr-x 0:0 8.6 kB │ ├── .data +-rw-r--r-- 0:0 6.4 kB │ │ ├── saved.again2.txt +-rwxrwxr-x 0:0 917 B │ │ ├── tag.sh +-rwxr-xr-x 0:0 1.3 kB │ │ └── test.sh +-rw-r--r-- 0:0 6.4 kB │ ├── .saved.txt +drwxr-xr-x 0:0 19 kB │ ├── example +drwxr-xr-x 0:0 0 B │ │ ├── really +drwxr-xr-x 0:0 0 B │ │ │ └── nested +-r--r--r-- 0:0 6.4 kB │ │ ├── somefile1.txt +-rw-r--r-- 0:0 6.4 kB │ │ ├── somefile2.txt +-rw-r--r-- 0:0 6.4 kB │ │ └── somefile3.txt +-rwxr-xr-x 0:0 6.4 kB │ └── saved.txt +-rw-rw-r-- 0:0 6.4 kB ├── somefile.txt +drwxrwxrwx 0:0 6.4 kB ├── tmp +-rw-r--r-- 0:0 6.4 kB │ └── saved.again1.txt +drwxr-xr-x 0:0 0 B ├── usr +drwxr-xr-x 1:1 0 B │ └── sbin +drwxr-xr-x 0:0 0 B └── var +drwxr-xr-x 0:0 0 B ├── spool +drwxr-xr-x 8:8 0 B │ └── mail +drwxr-xr-x 0:0 0 B └── www + diff --git a/ui/testdata/TestFileTreeDirCollapse.txt b/ui/testdata/TestFileTreeDirCollapse.txt new file mode 100644 index 0000000..09b1f31 --- /dev/null +++ b/ui/testdata/TestFileTreeDirCollapse.txt @@ -0,0 +1,13 @@ +drwxr-xr-x 0:0 1.2 MB ├─⊕ bin +drwxr-xr-x 0:0 0 B ├── dev +drwxr-xr-x 0:0 1.0 kB ├─⊕ etc +drwxr-xr-x 65534:65534 0 B ├── home +drwx------ 0:0 0 B ├── root +drwxrwxrwx 0:0 0 B ├── tmp +drwxr-xr-x 0:0 0 B ├── usr +drwxr-xr-x 1:1 0 B │ └── sbin +drwxr-xr-x 0:0 0 B └── var +drwxr-xr-x 0:0 0 B ├── spool +drwxr-xr-x 8:8 0 B │ └── mail +drwxr-xr-x 0:0 0 B └── www + diff --git a/ui/testdata/TestFileTreeDirCollapseAll.txt b/ui/testdata/TestFileTreeDirCollapseAll.txt new file mode 100644 index 0000000..581ba48 --- /dev/null +++ b/ui/testdata/TestFileTreeDirCollapseAll.txt @@ -0,0 +1,9 @@ +drwxr-xr-x 0:0 1.2 MB ├─⊕ bin +drwxr-xr-x 0:0 0 B ├── dev +drwxr-xr-x 0:0 1.0 kB ├─⊕ etc +drwxr-xr-x 65534:65534 0 B ├── home +drwx------ 0:0 0 B ├── root +drwxrwxrwx 0:0 0 B ├── tmp +drwxr-xr-x 0:0 0 B ├─⊕ usr +drwxr-xr-x 0:0 0 B └─⊕ var + diff --git a/ui/testdata/TestFileTreeDirCursorRight.txt b/ui/testdata/TestFileTreeDirCursorRight.txt new file mode 100644 index 0000000..c4126a9 --- /dev/null +++ b/ui/testdata/TestFileTreeDirCursorRight.txt @@ -0,0 +1,22 @@ +drwxr-xr-x 0:0 1.2 MB ├─⊕ bin +drwxr-xr-x 0:0 0 B ├── dev +drwxr-xr-x 0:0 1.0 kB ├── etc +-rw-rw-r-- 0:0 307 B │ ├── group +-rw-r--r-- 0:0 127 B │ ├── localtime +drwxr-xr-x 0:0 0 B │ ├── network +drwxr-xr-x 0:0 0 B │ │ ├── if-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d +drwxr-xr-x 0:0 0 B │ │ └── if-up.d +-rw-r--r-- 0:0 340 B │ ├── passwd +-rw------- 0:0 243 B │ └── shadow +drwxr-xr-x 65534:65534 0 B ├── home +drwx------ 0:0 0 B ├── root +drwxrwxrwx 0:0 0 B ├── tmp +drwxr-xr-x 0:0 0 B ├── usr +drwxr-xr-x 1:1 0 B │ └── sbin +drwxr-xr-x 0:0 0 B └── var +drwxr-xr-x 0:0 0 B ├── spool +drwxr-xr-x 8:8 0 B │ └── mail +drwxr-xr-x 0:0 0 B └── www + diff --git a/ui/testdata/TestFileTreeFilterTree.txt b/ui/testdata/TestFileTreeFilterTree.txt new file mode 100644 index 0000000..611e88b --- /dev/null +++ b/ui/testdata/TestFileTreeFilterTree.txt @@ -0,0 +1,7 @@ +drwxr-xr-x 0:0 0 B └── etc +drwxr-xr-x 0:0 0 B └── network +drwxr-xr-x 0:0 0 B ├── if-down.d +drwxr-xr-x 0:0 0 B ├── if-post-down.d +drwxr-xr-x 0:0 0 B ├── if-pre-up.d +drwxr-xr-x 0:0 0 B └── if-up.d + diff --git a/ui/testdata/TestFileTreeGoCase.txt b/ui/testdata/TestFileTreeGoCase.txt new file mode 100644 index 0000000..e86415c --- /dev/null +++ b/ui/testdata/TestFileTreeGoCase.txt @@ -0,0 +1,416 @@ +drwxr-xr-x 0:0 1.2 MB ├── bin +-rwxr-xr-x 0:0 1.1 MB │ ├── [ +-rwxr-xr-x 0:0 0 B │ ├── [[ → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── acpid → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── add-shell → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── addgroup → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── adduser → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── adjtimex → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ar → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── arch → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── arp → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── arping → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ash → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── awk → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── base64 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── basename → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── beep → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── blkdiscard → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── blkid → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── blockdev → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── bootchartd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── brctl → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── bunzip2 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── busybox → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── bzcat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── bzip2 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── cal → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── cat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chattr → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chgrp → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chmod → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chown → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chpasswd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chpst → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chroot → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chrt → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chvt → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── cksum → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── clear → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── cmp → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── comm → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── conspy → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── cp → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── cpio → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── crond → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── crontab → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── cryptpw → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── cttyhack → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── cut → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── date → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dc → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── deallocvt → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── delgroup → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── deluser → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── depmod → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── devmem → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── df → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dhcprelay → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── diff → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dirname → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dmesg → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dnsd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dnsdomainname → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dos2unix → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dpkg → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dpkg-deb → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── du → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dumpkmap → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── dumpleases → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── echo → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ed → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── egrep → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── eject → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── env → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── envdir → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── envuidgid → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ether-wake → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── expand → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── expr → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── factor → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fakeidentd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fallocate → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── false → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fatattr → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fbset → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fbsplash → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fdflush → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fdformat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fdisk → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fgconsole → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fgrep → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── find → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── findfs → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── flock → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fold → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── free → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── freeramdisk → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fsck → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fsck.minix → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fsfreeze → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fstrim → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fsync → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ftpd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ftpget → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ftpput → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── fuser → bin/[ +-rwxr-xr-x 0:0 78 kB │ ├── getconf +-rwxr-xr-x 0:0 0 B │ ├── getopt → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── getty → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── grep → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── groups → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── gunzip → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── gzip → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── halt → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── hd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── hdparm → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── head → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── hexdump → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── hexedit → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── hostid → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── hostname → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── httpd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── hush → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── hwclock → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── i2cdetect → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── i2cdump → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── i2cget → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── i2cset → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── id → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ifconfig → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ifdown → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ifenslave → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ifplugd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ifup → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── inetd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── init → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── insmod → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── install → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ionice → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── iostat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ip → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ipaddr → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ipcalc → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ipcrm → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ipcs → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── iplink → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ipneigh → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── iproute → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── iprule → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── iptunnel → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── kbd_mode → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── kill → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── killall → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── killall5 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── klogd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── last → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── less → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── link → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── linux32 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── linux64 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── linuxrc → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ln → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── loadfont → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── loadkmap → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── logger → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── login → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── logname → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── logread → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── losetup → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lpd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lpq → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lpr → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ls → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lsattr → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lsmod → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lsof → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lspci → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lsscsi → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lsusb → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lzcat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lzma → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── lzop → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── makedevs → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── makemime → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── man → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── md5sum → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mdev → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mesg → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── microcom → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mkdir → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mkdosfs → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mke2fs → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mkfifo → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mkfs.ext2 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mkfs.minix → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mkfs.vfat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mknod → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mkpasswd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mkswap → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mktemp → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── modinfo → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── modprobe → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── more → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mount → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mountpoint → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mpstat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mt → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── mv → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nameif → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nanddump → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nandwrite → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nbd-client → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nc → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── netstat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nice → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nl → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nmeter → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nohup → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nproc → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nsenter → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nslookup → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ntpd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── nuke → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── od → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── openvt → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── partprobe → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── passwd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── paste → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── patch → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pgrep → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pidof → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ping → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ping6 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pipe_progress → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pivot_root → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pkill → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pmap → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── popmaildir → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── poweroff → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── powertop → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── printenv → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── printf → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ps → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pscan → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pstree → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pwd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── pwdx → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── raidautorun → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rdate → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rdev → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── readahead → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── readlink → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── readprofile → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── realpath → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── reboot → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── reformime → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── remove-shell → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── renice → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── reset → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── resize → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── resume → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rev → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rm → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rmdir → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rmmod → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── route → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rpm → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rpm2cpio → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rtcwake → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── run-init → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── run-parts → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── runlevel → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── runsv → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── runsvdir → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── rx → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── script → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── scriptreplay → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sed → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sendmail → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── seq → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setarch → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setconsole → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setfattr → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setfont → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setkeycodes → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setlogcons → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setpriv → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setserial → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setsid → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── setuidgid → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sh → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sha1sum → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sha256sum → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sha3sum → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sha512sum → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── showkey → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── shred → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── shuf → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── slattach → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sleep → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── smemcap → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── softlimit → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sort → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── split → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ssl_client → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── start-stop-daemon → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── stat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── strings → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── stty → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── su → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sulogin → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sum → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sv → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── svc → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── svlogd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── svok → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── swapoff → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── swapon → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── switch_root → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sync → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── sysctl → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── syslogd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tac → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tail → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tar → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── taskset → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tc → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tcpsvd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tee → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── telnet → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── telnetd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── test → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tftp → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tftpd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── time → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── timeout → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── top → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── touch → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tr → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── traceroute → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── traceroute6 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── true → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── truncate → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tty → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ttysize → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── tunctl → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ubiattach → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ubidetach → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ubimkvol → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ubirename → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ubirmvol → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ubirsvol → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ubiupdatevol → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── udhcpc → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── udhcpd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── udpsvd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── uevent → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── umount → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── uname → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── unexpand → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── uniq → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── unix2dos → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── unlink → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── unlzma → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── unshare → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── unxz → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── unzip → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── uptime → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── users → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── usleep → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── uudecode → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── uuencode → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── vconfig → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── vi → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── vlock → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── volname → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── w → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── wall → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── watch → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── watchdog → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── wc → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── wget → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── which → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── who → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── whoami → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── whois → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── xargs → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── xxd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── xz → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── xzcat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── yes → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── zcat → bin/[ +-rwxr-xr-x 0:0 0 B │ └── zcip → bin/[ +drwxr-xr-x 0:0 0 B ├── dev +drwxr-xr-x 0:0 1.0 kB ├── etc +-rw-rw-r-- 0:0 307 B │ ├── group +-rw-r--r-- 0:0 127 B │ ├── localtime +drwxr-xr-x 0:0 0 B │ ├── network +drwxr-xr-x 0:0 0 B │ │ ├── if-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d +drwxr-xr-x 0:0 0 B │ │ └── if-up.d +-rw-r--r-- 0:0 340 B │ ├── passwd +-rw------- 0:0 243 B │ └── shadow +drwxr-xr-x 65534:65534 0 B ├── home +drwx------ 0:0 0 B ├── root +drwxrwxrwx 0:0 0 B ├── tmp +drwxr-xr-x 0:0 0 B ├── usr +drwxr-xr-x 1:1 0 B │ └── sbin +drwxr-xr-x 0:0 0 B └── var +drwxr-xr-x 0:0 0 B ├── spool +drwxr-xr-x 8:8 0 B │ └── mail +drwxr-xr-x 0:0 0 B └── www + diff --git a/ui/testdata/TestFileTreeHideAddedRemovedModified.txt b/ui/testdata/TestFileTreeHideAddedRemovedModified.txt new file mode 100644 index 0000000..a34de17 --- /dev/null +++ b/ui/testdata/TestFileTreeHideAddedRemovedModified.txt @@ -0,0 +1,21 @@ +drwxr-xr-x 0:0 1.2 MB ├─⊕ bin +drwxr-xr-x 0:0 0 B ├── dev +drwxr-xr-x 0:0 1.0 kB ├── etc +-rw-rw-r-- 0:0 307 B │ ├── group +-rw-r--r-- 0:0 127 B │ ├── localtime +drwxr-xr-x 0:0 0 B │ ├── network +drwxr-xr-x 0:0 0 B │ │ ├── if-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d +drwxr-xr-x 0:0 0 B │ │ └── if-up.d +-rw-r--r-- 0:0 340 B │ ├── passwd +-rw------- 0:0 243 B │ └── shadow +drwxr-xr-x 65534:65534 0 B ├── home +drwxrwxrwx 0:0 0 B ├── tmp +drwxr-xr-x 0:0 0 B ├── usr +drwxr-xr-x 1:1 0 B │ └── sbin +drwxr-xr-x 0:0 0 B └── var +drwxr-xr-x 0:0 0 B ├── spool +drwxr-xr-x 8:8 0 B │ └── mail +drwxr-xr-x 0:0 0 B └── www + diff --git a/ui/testdata/TestFileTreeHideTypeWithFilter.txt b/ui/testdata/TestFileTreeHideTypeWithFilter.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ui/testdata/TestFileTreeHideTypeWithFilter.txt @@ -0,0 +1 @@ + diff --git a/ui/testdata/TestFileTreeHideUnmodified.txt b/ui/testdata/TestFileTreeHideUnmodified.txt new file mode 100644 index 0000000..28c760f --- /dev/null +++ b/ui/testdata/TestFileTreeHideUnmodified.txt @@ -0,0 +1,10 @@ +drwx------ 0:0 19 kB ├── root +drwxr-xr-x 0:0 13 kB │ ├── example +drwxr-xr-x 0:0 0 B │ │ ├── really +drwxr-xr-x 0:0 0 B │ │ │ └── nested +-r--r--r-- 0:0 6.4 kB │ │ ├── somefile1.txt +-rw-r--r-- 0:0 6.4 kB │ │ ├── somefile2.txt +-rw-r--r-- 0:0 6.4 kB │ │ └── somefile3.txt +-rw-r--r-- 0:0 6.4 kB │ └── saved.txt +-rw-rw-r-- 0:0 6.4 kB └── somefile.txt + diff --git a/ui/testdata/TestFileTreeNoAttributes.txt b/ui/testdata/TestFileTreeNoAttributes.txt new file mode 100644 index 0000000..3b2c5df --- /dev/null +++ b/ui/testdata/TestFileTreeNoAttributes.txt @@ -0,0 +1,416 @@ +├── bin +│ ├── [ +│ ├── [[ → bin/[ +│ ├── acpid → bin/[ +│ ├── add-shell → bin/[ +│ ├── addgroup → bin/[ +│ ├── adduser → bin/[ +│ ├── adjtimex → bin/[ +│ ├── ar → bin/[ +│ ├── arch → bin/[ +│ ├── arp → bin/[ +│ ├── arping → bin/[ +│ ├── ash → bin/[ +│ ├── awk → bin/[ +│ ├── base64 → bin/[ +│ ├── basename → bin/[ +│ ├── beep → bin/[ +│ ├── blkdiscard → bin/[ +│ ├── blkid → bin/[ +│ ├── blockdev → bin/[ +│ ├── bootchartd → bin/[ +│ ├── brctl → bin/[ +│ ├── bunzip2 → bin/[ +│ ├── busybox → bin/[ +│ ├── bzcat → bin/[ +│ ├── bzip2 → bin/[ +│ ├── cal → bin/[ +│ ├── cat → bin/[ +│ ├── chat → bin/[ +│ ├── chattr → bin/[ +│ ├── chgrp → bin/[ +│ ├── chmod → bin/[ +│ ├── chown → bin/[ +│ ├── chpasswd → bin/[ +│ ├── chpst → bin/[ +│ ├── chroot → bin/[ +│ ├── chrt → bin/[ +│ ├── chvt → bin/[ +│ ├── cksum → bin/[ +│ ├── clear → bin/[ +│ ├── cmp → bin/[ +│ ├── comm → bin/[ +│ ├── conspy → bin/[ +│ ├── cp → bin/[ +│ ├── cpio → bin/[ +│ ├── crond → bin/[ +│ ├── crontab → bin/[ +│ ├── cryptpw → bin/[ +│ ├── cttyhack → bin/[ +│ ├── cut → bin/[ +│ ├── date → bin/[ +│ ├── dc → bin/[ +│ ├── dd → bin/[ +│ ├── deallocvt → bin/[ +│ ├── delgroup → bin/[ +│ ├── deluser → bin/[ +│ ├── depmod → bin/[ +│ ├── devmem → bin/[ +│ ├── df → bin/[ +│ ├── dhcprelay → bin/[ +│ ├── diff → bin/[ +│ ├── dirname → bin/[ +│ ├── dmesg → bin/[ +│ ├── dnsd → bin/[ +│ ├── dnsdomainname → bin/[ +│ ├── dos2unix → bin/[ +│ ├── dpkg → bin/[ +│ ├── dpkg-deb → bin/[ +│ ├── du → bin/[ +│ ├── dumpkmap → bin/[ +│ ├── dumpleases → bin/[ +│ ├── echo → bin/[ +│ ├── ed → bin/[ +│ ├── egrep → bin/[ +│ ├── eject → bin/[ +│ ├── env → bin/[ +│ ├── envdir → bin/[ +│ ├── envuidgid → bin/[ +│ ├── ether-wake → bin/[ +│ ├── expand → bin/[ +│ ├── expr → bin/[ +│ ├── factor → bin/[ +│ ├── fakeidentd → bin/[ +│ ├── fallocate → bin/[ +│ ├── false → bin/[ +│ ├── fatattr → bin/[ +│ ├── fbset → bin/[ +│ ├── fbsplash → bin/[ +│ ├── fdflush → bin/[ +│ ├── fdformat → bin/[ +│ ├── fdisk → bin/[ +│ ├── fgconsole → bin/[ +│ ├── fgrep → bin/[ +│ ├── find → bin/[ +│ ├── findfs → bin/[ +│ ├── flock → bin/[ +│ ├── fold → bin/[ +│ ├── free → bin/[ +│ ├── freeramdisk → bin/[ +│ ├── fsck → bin/[ +│ ├── fsck.minix → bin/[ +│ ├── fsfreeze → bin/[ +│ ├── fstrim → bin/[ +│ ├── fsync → bin/[ +│ ├── ftpd → bin/[ +│ ├── ftpget → bin/[ +│ ├── ftpput → bin/[ +│ ├── fuser → bin/[ +│ ├── getconf +│ ├── getopt → bin/[ +│ ├── getty → bin/[ +│ ├── grep → bin/[ +│ ├── groups → bin/[ +│ ├── gunzip → bin/[ +│ ├── gzip → bin/[ +│ ├── halt → bin/[ +│ ├── hd → bin/[ +│ ├── hdparm → bin/[ +│ ├── head → bin/[ +│ ├── hexdump → bin/[ +│ ├── hexedit → bin/[ +│ ├── hostid → bin/[ +│ ├── hostname → bin/[ +│ ├── httpd → bin/[ +│ ├── hush → bin/[ +│ ├── hwclock → bin/[ +│ ├── i2cdetect → bin/[ +│ ├── i2cdump → bin/[ +│ ├── i2cget → bin/[ +│ ├── i2cset → bin/[ +│ ├── id → bin/[ +│ ├── ifconfig → bin/[ +│ ├── ifdown → bin/[ +│ ├── ifenslave → bin/[ +│ ├── ifplugd → bin/[ +│ ├── ifup → bin/[ +│ ├── inetd → bin/[ +│ ├── init → bin/[ +│ ├── insmod → bin/[ +│ ├── install → bin/[ +│ ├── ionice → bin/[ +│ ├── iostat → bin/[ +│ ├── ip → bin/[ +│ ├── ipaddr → bin/[ +│ ├── ipcalc → bin/[ +│ ├── ipcrm → bin/[ +│ ├── ipcs → bin/[ +│ ├── iplink → bin/[ +│ ├── ipneigh → bin/[ +│ ├── iproute → bin/[ +│ ├── iprule → bin/[ +│ ├── iptunnel → bin/[ +│ ├── kbd_mode → bin/[ +│ ├── kill → bin/[ +│ ├── killall → bin/[ +│ ├── killall5 → bin/[ +│ ├── klogd → bin/[ +│ ├── last → bin/[ +│ ├── less → bin/[ +│ ├── link → bin/[ +│ ├── linux32 → bin/[ +│ ├── linux64 → bin/[ +│ ├── linuxrc → bin/[ +│ ├── ln → bin/[ +│ ├── loadfont → bin/[ +│ ├── loadkmap → bin/[ +│ ├── logger → bin/[ +│ ├── login → bin/[ +│ ├── logname → bin/[ +│ ├── logread → bin/[ +│ ├── losetup → bin/[ +│ ├── lpd → bin/[ +│ ├── lpq → bin/[ +│ ├── lpr → bin/[ +│ ├── ls → bin/[ +│ ├── lsattr → bin/[ +│ ├── lsmod → bin/[ +│ ├── lsof → bin/[ +│ ├── lspci → bin/[ +│ ├── lsscsi → bin/[ +│ ├── lsusb → bin/[ +│ ├── lzcat → bin/[ +│ ├── lzma → bin/[ +│ ├── lzop → bin/[ +│ ├── makedevs → bin/[ +│ ├── makemime → bin/[ +│ ├── man → bin/[ +│ ├── md5sum → bin/[ +│ ├── mdev → bin/[ +│ ├── mesg → bin/[ +│ ├── microcom → bin/[ +│ ├── mkdir → bin/[ +│ ├── mkdosfs → bin/[ +│ ├── mke2fs → bin/[ +│ ├── mkfifo → bin/[ +│ ├── mkfs.ext2 → bin/[ +│ ├── mkfs.minix → bin/[ +│ ├── mkfs.vfat → bin/[ +│ ├── mknod → bin/[ +│ ├── mkpasswd → bin/[ +│ ├── mkswap → bin/[ +│ ├── mktemp → bin/[ +│ ├── modinfo → bin/[ +│ ├── modprobe → bin/[ +│ ├── more → bin/[ +│ ├── mount → bin/[ +│ ├── mountpoint → bin/[ +│ ├── mpstat → bin/[ +│ ├── mt → bin/[ +│ ├── mv → bin/[ +│ ├── nameif → bin/[ +│ ├── nanddump → bin/[ +│ ├── nandwrite → bin/[ +│ ├── nbd-client → bin/[ +│ ├── nc → bin/[ +│ ├── netstat → bin/[ +│ ├── nice → bin/[ +│ ├── nl → bin/[ +│ ├── nmeter → bin/[ +│ ├── nohup → bin/[ +│ ├── nproc → bin/[ +│ ├── nsenter → bin/[ +│ ├── nslookup → bin/[ +│ ├── ntpd → bin/[ +│ ├── nuke → bin/[ +│ ├── od → bin/[ +│ ├── openvt → bin/[ +│ ├── partprobe → bin/[ +│ ├── passwd → bin/[ +│ ├── paste → bin/[ +│ ├── patch → bin/[ +│ ├── pgrep → bin/[ +│ ├── pidof → bin/[ +│ ├── ping → bin/[ +│ ├── ping6 → bin/[ +│ ├── pipe_progress → bin/[ +│ ├── pivot_root → bin/[ +│ ├── pkill → bin/[ +│ ├── pmap → bin/[ +│ ├── popmaildir → bin/[ +│ ├── poweroff → bin/[ +│ ├── powertop → bin/[ +│ ├── printenv → bin/[ +│ ├── printf → bin/[ +│ ├── ps → bin/[ +│ ├── pscan → bin/[ +│ ├── pstree → bin/[ +│ ├── pwd → bin/[ +│ ├── pwdx → bin/[ +│ ├── raidautorun → bin/[ +│ ├── rdate → bin/[ +│ ├── rdev → bin/[ +│ ├── readahead → bin/[ +│ ├── readlink → bin/[ +│ ├── readprofile → bin/[ +│ ├── realpath → bin/[ +│ ├── reboot → bin/[ +│ ├── reformime → bin/[ +│ ├── remove-shell → bin/[ +│ ├── renice → bin/[ +│ ├── reset → bin/[ +│ ├── resize → bin/[ +│ ├── resume → bin/[ +│ ├── rev → bin/[ +│ ├── rm → bin/[ +│ ├── rmdir → bin/[ +│ ├── rmmod → bin/[ +│ ├── route → bin/[ +│ ├── rpm → bin/[ +│ ├── rpm2cpio → bin/[ +│ ├── rtcwake → bin/[ +│ ├── run-init → bin/[ +│ ├── run-parts → bin/[ +│ ├── runlevel → bin/[ +│ ├── runsv → bin/[ +│ ├── runsvdir → bin/[ +│ ├── rx → bin/[ +│ ├── script → bin/[ +│ ├── scriptreplay → bin/[ +│ ├── sed → bin/[ +│ ├── sendmail → bin/[ +│ ├── seq → bin/[ +│ ├── setarch → bin/[ +│ ├── setconsole → bin/[ +│ ├── setfattr → bin/[ +│ ├── setfont → bin/[ +│ ├── setkeycodes → bin/[ +│ ├── setlogcons → bin/[ +│ ├── setpriv → bin/[ +│ ├── setserial → bin/[ +│ ├── setsid → bin/[ +│ ├── setuidgid → bin/[ +│ ├── sh → bin/[ +│ ├── sha1sum → bin/[ +│ ├── sha256sum → bin/[ +│ ├── sha3sum → bin/[ +│ ├── sha512sum → bin/[ +│ ├── showkey → bin/[ +│ ├── shred → bin/[ +│ ├── shuf → bin/[ +│ ├── slattach → bin/[ +│ ├── sleep → bin/[ +│ ├── smemcap → bin/[ +│ ├── softlimit → bin/[ +│ ├── sort → bin/[ +│ ├── split → bin/[ +│ ├── ssl_client → bin/[ +│ ├── start-stop-daemon → bin/[ +│ ├── stat → bin/[ +│ ├── strings → bin/[ +│ ├── stty → bin/[ +│ ├── su → bin/[ +│ ├── sulogin → bin/[ +│ ├── sum → bin/[ +│ ├── sv → bin/[ +│ ├── svc → bin/[ +│ ├── svlogd → bin/[ +│ ├── svok → bin/[ +│ ├── swapoff → bin/[ +│ ├── swapon → bin/[ +│ ├── switch_root → bin/[ +│ ├── sync → bin/[ +│ ├── sysctl → bin/[ +│ ├── syslogd → bin/[ +│ ├── tac → bin/[ +│ ├── tail → bin/[ +│ ├── tar → bin/[ +│ ├── taskset → bin/[ +│ ├── tc → bin/[ +│ ├── tcpsvd → bin/[ +│ ├── tee → bin/[ +│ ├── telnet → bin/[ +│ ├── telnetd → bin/[ +│ ├── test → bin/[ +│ ├── tftp → bin/[ +│ ├── tftpd → bin/[ +│ ├── time → bin/[ +│ ├── timeout → bin/[ +│ ├── top → bin/[ +│ ├── touch → bin/[ +│ ├── tr → bin/[ +│ ├── traceroute → bin/[ +│ ├── traceroute6 → bin/[ +│ ├── true → bin/[ +│ ├── truncate → bin/[ +│ ├── tty → bin/[ +│ ├── ttysize → bin/[ +│ ├── tunctl → bin/[ +│ ├── ubiattach → bin/[ +│ ├── ubidetach → bin/[ +│ ├── ubimkvol → bin/[ +│ ├── ubirename → bin/[ +│ ├── ubirmvol → bin/[ +│ ├── ubirsvol → bin/[ +│ ├── ubiupdatevol → bin/[ +│ ├── udhcpc → bin/[ +│ ├── udhcpd → bin/[ +│ ├── udpsvd → bin/[ +│ ├── uevent → bin/[ +│ ├── umount → bin/[ +│ ├── uname → bin/[ +│ ├── unexpand → bin/[ +│ ├── uniq → bin/[ +│ ├── unix2dos → bin/[ +│ ├── unlink → bin/[ +│ ├── unlzma → bin/[ +│ ├── unshare → bin/[ +│ ├── unxz → bin/[ +│ ├── unzip → bin/[ +│ ├── uptime → bin/[ +│ ├── users → bin/[ +│ ├── usleep → bin/[ +│ ├── uudecode → bin/[ +│ ├── uuencode → bin/[ +│ ├── vconfig → bin/[ +│ ├── vi → bin/[ +│ ├── vlock → bin/[ +│ ├── volname → bin/[ +│ ├── w → bin/[ +│ ├── wall → bin/[ +│ ├── watch → bin/[ +│ ├── watchdog → bin/[ +│ ├── wc → bin/[ +│ ├── wget → bin/[ +│ ├── which → bin/[ +│ ├── who → bin/[ +│ ├── whoami → bin/[ +│ ├── whois → bin/[ +│ ├── xargs → bin/[ +│ ├── xxd → bin/[ +│ ├── xz → bin/[ +│ ├── xzcat → bin/[ +│ ├── yes → bin/[ +│ ├── zcat → bin/[ +│ └── zcip → bin/[ +├── dev +├── etc +│ ├── group +│ ├── localtime +│ ├── network +│ │ ├── if-down.d +│ │ ├── if-post-down.d +│ │ ├── if-pre-up.d +│ │ └── if-up.d +│ ├── passwd +│ └── shadow +├── home +├── root +├── tmp +├── usr +│ └── sbin +└── var + ├── spool + │ └── mail + └── www + diff --git a/ui/testdata/TestFileTreePageDown.txt b/ui/testdata/TestFileTreePageDown.txt new file mode 100644 index 0000000..ae5494f --- /dev/null +++ b/ui/testdata/TestFileTreePageDown.txt @@ -0,0 +1,11 @@ +-rwxr-xr-x 0:0 0 B │ ├── cat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chat → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chattr → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chgrp → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chmod → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chown → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chpasswd → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chpst → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chroot → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── chrt → bin/[ + diff --git a/ui/testdata/TestFileTreePageUp.txt b/ui/testdata/TestFileTreePageUp.txt new file mode 100644 index 0000000..feafe48 --- /dev/null +++ b/ui/testdata/TestFileTreePageUp.txt @@ -0,0 +1,11 @@ +-rwxr-xr-x 0:0 0 B │ ├── arch → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── arp → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── arping → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── ash → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── awk → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── base64 → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── basename → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── beep → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── blkdiscard → bin/[ +-rwxr-xr-x 0:0 0 B │ ├── blkid → bin/[ + diff --git a/ui/testdata/TestFileTreeRestrictedHeight.txt b/ui/testdata/TestFileTreeRestrictedHeight.txt new file mode 100644 index 0000000..69e10f0 --- /dev/null +++ b/ui/testdata/TestFileTreeRestrictedHeight.txt @@ -0,0 +1,22 @@ +├── bin +│ ├── [ +│ ├── [[ → bin/[ +│ ├── acpid → bin/[ +│ ├── add-shell → bin/[ +│ ├── addgroup → bin/[ +│ ├── adduser → bin/[ +│ ├── adjtimex → bin/[ +│ ├── ar → bin/[ +│ ├── arch → bin/[ +│ ├── arp → bin/[ +│ ├── arping → bin/[ +│ ├── ash → bin/[ +│ ├── awk → bin/[ +│ ├── base64 → bin/[ +│ ├── basename → bin/[ +│ ├── beep → bin/[ +│ ├── blkdiscard → bin/[ +│ ├── blkid → bin/[ +│ ├── blockdev → bin/[ +│ ├── bootchartd → bin/[ + diff --git a/ui/testdata/TestFileTreeSelectLayer.txt b/ui/testdata/TestFileTreeSelectLayer.txt new file mode 100644 index 0000000..360737b --- /dev/null +++ b/ui/testdata/TestFileTreeSelectLayer.txt @@ -0,0 +1,23 @@ +drwxr-xr-x 0:0 1.2 MB ├─⊕ bin +drwxr-xr-x 0:0 0 B ├── dev +drwxr-xr-x 0:0 1.0 kB ├── etc +-rw-rw-r-- 0:0 307 B │ ├── group +-rw-r--r-- 0:0 127 B │ ├── localtime +drwxr-xr-x 0:0 0 B │ ├── network +drwxr-xr-x 0:0 0 B │ │ ├── if-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d +drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d +drwxr-xr-x 0:0 0 B │ │ └── if-up.d +-rw-r--r-- 0:0 340 B │ ├── passwd +-rw------- 0:0 243 B │ └── shadow +drwxr-xr-x 65534:65534 0 B ├── home +drwx------ 0:0 0 B ├── root +-rw-rw-r-- 0:0 6.4 kB ├── somefile.txt +drwxrwxrwx 0:0 0 B ├── tmp +drwxr-xr-x 0:0 0 B ├── usr +drwxr-xr-x 1:1 0 B │ └── sbin +drwxr-xr-x 0:0 0 B └── var +drwxr-xr-x 0:0 0 B ├── spool +drwxr-xr-x 8:8 0 B │ └── mail +drwxr-xr-x 0:0 0 B └── www + diff --git a/ui/ui.go b/ui/ui.go index f2443ae..d85065b 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -11,7 +11,6 @@ import ( "github.com/wagoodman/dive/image" "github.com/wagoodman/dive/utils" "github.com/wagoodman/keybinding" - "log" ) const debug = false @@ -21,8 +20,8 @@ const debug = false // 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") + if debug && Controllers.Tree != nil && Controllers.Tree.gui != nil { + v, _ := Controllers.Tree.gui.View("debug") if v != nil { if len(v.BufferLines()) > 20 { v.Clear() @@ -44,13 +43,13 @@ var Formatting struct { CompareBottom func(...interface{}) string } -// Views contains all rendered UI panes. -var Views struct { - Tree *FileTreeView - Layer *LayerView - Status *StatusView - Filter *FilterView - Details *DetailsView +// Controllers contains all rendered UI panes. +var Controllers struct { + Tree *FileTreeController + Layer *LayerController + Status *StatusController + Filter *FilterController + Details *DetailsController lookup map[string]View } @@ -72,14 +71,12 @@ type View interface { } // 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) - Update() - Render() - return err +func toggleView(g *gocui.Gui, v *gocui.View) (err error) { + if v == nil || v.Name() == Controllers.Layer.Name { + _, err = g.SetCurrentView(Controllers.Tree.Name) + } else { + _, err = g.SetCurrentView(Controllers.Layer.Name) } - _, err := g.SetCurrentView(Views.Layer.Name) Update() Render() return err @@ -88,14 +85,14 @@ func toggleView(g *gocui.Gui, v *gocui.View) error { // 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() - Views.Filter.view.SetCursor(0, 0) + Controllers.Filter.view.Clear() + Controllers.Filter.view.SetCursor(0, 0) // toggle hiding - Views.Filter.hidden = !Views.Filter.hidden + Controllers.Filter.hidden = !Controllers.Filter.hidden - if !Views.Filter.hidden { - _, err := g.SetCurrentView(Views.Filter.Name) + if !Controllers.Filter.hidden { + _, err := g.SetCurrentView(Controllers.Filter.Name) if err != nil { return err } @@ -210,7 +207,7 @@ func layout(g *gocui.Gui) error { statusBarIndex := 1 filterBarIndex := 2 - layersHeight := len(Views.Layer.Layers) + headerRows + 1 // layers + header + base image layer row + layersHeight := len(Controllers.Layer.Layers) + headerRows + 1 // layers + header + base image layer row maxLayerHeight := int(0.75 * float64(maxY)) if layersHeight > maxLayerHeight { layersHeight = maxLayerHeight @@ -219,7 +216,7 @@ func layout(g *gocui.Gui) error { var view, header *gocui.View var viewErr, headerErr, err error - if Views.Filter.hidden { + if Controllers.Filter.hidden { bottomRows-- filterBarHeight = 0 } @@ -234,43 +231,48 @@ func layout(g *gocui.Gui) error { } // Layers - view, viewErr = g.SetView(Views.Layer.Name, -1, -1+headerRows, splitCols, layersHeight) - header, headerErr = g.SetView(Views.Layer.Name+"header", -1, -1, splitCols, headerRows) + view, viewErr = g.SetView(Controllers.Layer.Name, -1, -1+headerRows, splitCols, layersHeight) + header, headerErr = g.SetView(Controllers.Layer.Name+"header", -1, -1, splitCols, headerRows) if isNewView(viewErr, headerErr) { - Views.Layer.Setup(view, header) + Controllers.Layer.Setup(view, header) - if _, err = g.SetCurrentView(Views.Layer.Name); err != nil { + if _, err = g.SetCurrentView(Controllers.Layer.Name); err != nil { return err } // since we are selecting the view, we should rerender to indicate it is selected - Views.Layer.Render() + Controllers.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) + view, viewErr = g.SetView(Controllers.Details.Name, -1, -1+layersHeight+headerRows, splitCols, maxY-bottomRows) + header, headerErr = g.SetView(Controllers.Details.Name+"header", -1, -1+layersHeight, splitCols, layersHeight+headerRows) if isNewView(viewErr, headerErr) { - Views.Details.Setup(view, header) + Controllers.Details.Setup(view, header) } // Filetree - view, viewErr = g.SetView(Views.Tree.Name, splitCols, -1+headerRows, debugCols, maxY-bottomRows) - header, headerErr = g.SetView(Views.Tree.Name+"header", splitCols, -1, debugCols, headerRows) - if isNewView(viewErr, headerErr) { - Views.Tree.Setup(view, header) + offset := 0 + if !Controllers.Tree.vm.ShowAttributes { + offset = 1 } + view, viewErr = g.SetView(Controllers.Tree.Name, splitCols, -1+headerRows-offset, debugCols, maxY-bottomRows) + header, headerErr = g.SetView(Controllers.Tree.Name+"header", splitCols, -1, debugCols, headerRows-offset) + if isNewView(viewErr, headerErr) { + Controllers.Tree.Setup(view, header) + } + Controllers.Tree.onLayoutChange() // Status Bar - view, viewErr = g.SetView(Views.Status.Name, -1, maxY-statusBarHeight-statusBarIndex, maxX, maxY-(statusBarIndex-1)) + view, viewErr = g.SetView(Controllers.Status.Name, -1, maxY-statusBarHeight-statusBarIndex, maxX, maxY-(statusBarIndex-1)) if isNewView(viewErr, headerErr) { - Views.Status.Setup(view, nil) + Controllers.Status.Setup(view, nil) } // Filter Bar - view, viewErr = g.SetView(Views.Filter.Name, len(Views.Filter.headerStr)-1, maxY-filterBarHeight-filterBarIndex, maxX, maxY-(filterBarIndex-1)) - header, headerErr = g.SetView(Views.Filter.Name+"header", -1, maxY-filterBarHeight-filterBarIndex, len(Views.Filter.headerStr), maxY-(filterBarIndex-1)) + view, viewErr = g.SetView(Controllers.Filter.Name, len(Controllers.Filter.headerStr)-1, maxY-filterBarHeight-filterBarIndex, maxX, maxY-(filterBarIndex-1)) + header, headerErr = g.SetView(Controllers.Filter.Name+"header", -1, maxY-filterBarHeight-filterBarIndex, len(Controllers.Filter.headerStr), maxY-(filterBarIndex-1)) if isNewView(viewErr, headerErr) { - Views.Filter.Setup(view, header) + Controllers.Filter.Setup(view, header) } return nil @@ -278,14 +280,14 @@ func layout(g *gocui.Gui) error { // Update refreshes the state objects for future rendering. func Update() { - for _, view := range Views.lookup { + for _, view := range Controllers.lookup { view.Update() } } // Render flushes the state objects to the screen. func Render() { - for _, view := range Views.lookup { + for _, view := range Controllers.lookup { if view.IsVisible() { view.Render() } @@ -316,40 +318,40 @@ func Run(analysis *image.AnalysisResult, cache filetree.TreeCache) { var err error GlobalKeybindings.quit, err = keybinding.ParseAll(viper.GetString("keybinding.quit")) if err != nil { - log.Panicln(err) + logrus.Error(err) } GlobalKeybindings.toggleView, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-view")) if err != nil { - log.Panicln(err) + logrus.Error(err) } GlobalKeybindings.filterView, err = keybinding.ParseAll(viper.GetString("keybinding.filter-files")) if err != nil { - log.Panicln(err) + logrus.Error(err) } g, err := gocui.NewGui(gocui.OutputNormal) if err != nil { - log.Panicln(err) + logrus.Error(err) } utils.SetUi(g) defer g.Close() - Views.lookup = make(map[string]View) + Controllers.lookup = make(map[string]View) - Views.Layer = NewLayerView("side", g, analysis.Layers) - Views.lookup[Views.Layer.Name] = Views.Layer + Controllers.Layer = NewLayerController("side", g, analysis.Layers) + Controllers.lookup[Controllers.Layer.Name] = Controllers.Layer - Views.Tree = NewFileTreeView("main", g, filetree.StackTreeRange(analysis.RefTrees, 0, 0), analysis.RefTrees, cache) - Views.lookup[Views.Tree.Name] = Views.Tree + Controllers.Tree = NewFileTreeController("main", g, filetree.StackTreeRange(analysis.RefTrees, 0, 0), analysis.RefTrees, cache) + Controllers.lookup[Controllers.Tree.Name] = Controllers.Tree - Views.Status = NewStatusView("status", g) - Views.lookup[Views.Status.Name] = Views.Status + Controllers.Status = NewStatusController("status", g) + Controllers.lookup[Controllers.Status.Name] = Controllers.Status - Views.Filter = NewFilterView("command", g) - Views.lookup[Views.Filter.Name] = Views.Filter + Controllers.Filter = NewFilterController("command", g) + Controllers.lookup[Controllers.Filter.Name] = Controllers.Filter - Views.Details = NewDetailsView("details", g, analysis.Efficiency, analysis.Inefficiencies) - Views.lookup[Views.Details.Name] = Views.Details + Controllers.Details = NewDetailsController("details", g, analysis.Efficiency, analysis.Inefficiencies) + Controllers.lookup[Controllers.Details.Name] = Controllers.Details g.Cursor = false //g.Mouse = true @@ -366,11 +368,11 @@ func Run(analysis *image.AnalysisResult, cache filetree.TreeCache) { Render() if err := keyBindings(g); err != nil { - log.Panicln(err) + logrus.Error(err) } if err := g.MainLoop(); err != nil && err != gocui.ErrQuit { - log.Panicln(err) + logrus.Error(err) } utils.Exit(0) }