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
This commit is contained in:
Alex Goodman 2019-02-22 11:49:53 -05:00 committed by GitHub
parent cf8900da84
commit 993be8d3ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 2991 additions and 1359 deletions

View file

@ -188,6 +188,7 @@ Key Binding | Description
<kbd>Ctrl + R</kbd> | Filetree view: show/hide removed files
<kbd>Ctrl + M</kbd> | Filetree view: show/hide modified files
<kbd>Ctrl + U</kbd> | Filetree view: show/hide unmodified files
<kbd>Ctrl + B</kbd> | 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

View file

@ -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

View file

@ -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

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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())
}

151
ui/details_controller.go Normal file
View file

@ -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"
}

View file

@ -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"
}

395
ui/filetree_controller.go Normal file
View file

@ -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)
}

426
ui/filetree_viewmodel.go Normal file
View file

@ -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
}

View file

@ -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)
}

View file

@ -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])
}

116
ui/filter_controller.go Normal file
View file

@ -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 ")
}

View file

@ -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 ")
}

315
ui/layer_controller.go Normal file
View file

@ -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)
}

View file

@ -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)
}

81
ui/status_controller.go Normal file
View file

@ -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())
}

View file

@ -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())
}

View file

@ -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

13
ui/testdata/TestFileTreeDirCollapse.txt vendored Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

416
ui/testdata/TestFileTreeGoCase.txt vendored Normal file
View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@

View file

@ -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

416
ui/testdata/TestFileTreeNoAttributes.txt vendored Normal file
View file

@ -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

11
ui/testdata/TestFileTreePageDown.txt vendored Normal file
View file

@ -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/[

11
ui/testdata/TestFileTreePageUp.txt vendored Normal file
View file

@ -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/[

View file

@ -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/[

23
ui/testdata/TestFileTreeSelectLayer.txt vendored Normal file
View file

@ -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

122
ui/ui.go
View file

@ -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)
}