added simple layout manager

This commit is contained in:
Alex Goodman 2019-11-22 17:33:13 -05:00
parent 119040e72c
commit 14706152a1
No known key found for this signature in database
GPG key ID: 150587AB82D3C4E6
16 changed files with 867 additions and 590 deletions

View file

@ -141,6 +141,10 @@ func initLogging() {
log.SetLevel(level)
log.Debug("Starting Dive...")
log.Debugf("config filepath: %s", viper.ConfigFileUsed())
for k, v := range viper.AllSettings() {
log.Debug("config value: ", k, " : ", v)
}
}
// getCfgFile checks for config file in paths from xdg specs

View file

@ -3,6 +3,8 @@ package ui
import (
"github.com/wagoodman/dive/dive/image"
"github.com/wagoodman/dive/runtime/ui/key"
"github.com/wagoodman/dive/runtime/ui/layout"
"github.com/wagoodman/dive/runtime/ui/layout/compound"
"sync"
"github.com/jroimartin/gocui"
@ -16,7 +18,7 @@ const debug = false
type app struct {
gui *gocui.Gui
controllers *Controller
layout *layoutManager
layout *layout.Manager
}
var (
@ -27,19 +29,24 @@ var (
func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Comparer) (*app, error) {
var err error
once.Do(func() {
var theControls *Controller
var controller *Controller
var globalHelpKeys []*key.Binding
theControls, err = NewCollection(gui, analysis, cache)
controller, err = NewCollection(gui, analysis, cache)
if err != nil {
return
}
lm := newLayoutManager(theControls)
// note: order matters when adding elements to the layout
lm := layout.NewManager()
lm.Add(controller.views.Status, layout.LocationFooter)
lm.Add(controller.views.Filter, layout.LocationFooter)
lm.Add(compound.NewLayerDetailsCompoundLayout(controller.views.Layer, controller.views.Details), layout.LocationColumn)
lm.Add(controller.views.Tree, layout.LocationColumn)
gui.Cursor = false
//g.Mouse = true
gui.SetManagerFunc(lm.layout)
gui.SetManagerFunc(lm.Layout)
// var profileObj = profile.Start(profile.CPUProfile, profile.ProfilePath("."), profile.NoShutdownHook)
//
@ -49,7 +56,7 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa
appSingleton = &app{
gui: gui,
controllers: theControls,
controllers: controller,
layout: lm,
}
@ -61,13 +68,13 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa
},
{
ConfigKeys: []string{"keybinding.toggle-view"},
OnAction: theControls.ToggleView,
OnAction: controller.ToggleView,
Display: "Switch view",
},
{
ConfigKeys: []string{"keybinding.filter-files"},
OnAction: theControls.ToggleFilterView,
IsSelected: theControls.Filter.IsVisible,
OnAction: controller.ToggleFilterView,
IsSelected: controller.views.Filter.IsVisible,
Display: "Filter",
},
}
@ -77,10 +84,10 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa
return
}
theControls.Status.AddHelpKeys(globalHelpKeys...)
controller.views.Status.AddHelpKeys(globalHelpKeys...)
// perform the first update and render now that all resources have been loaded
err = theControls.UpdateAndRender()
err = controller.UpdateAndRender()
if err != nil {
return
}
@ -106,8 +113,6 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa
// }
// }
var lastX, lastY int
// quit is the gocui callback invoked when the user hits Ctrl+C
func (a *app) quit() error {

View file

@ -11,61 +11,33 @@ import (
)
type Controller struct {
gui *gocui.Gui
Tree *view.FileTree
Layer *view.Layer
Status *view.Status
Filter *view.Filter
Details *view.Details
lookup map[string]view.Renderer
gui *gocui.Gui
views *view.Views
}
func NewCollection(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Comparer) (*Controller, error) {
var err error
views, err := view.NewViews(g, analysis, cache)
if err != nil {
return nil, err
}
controller := &Controller{
gui: g,
gui: g,
views: views,
}
controller.lookup = make(map[string]view.Renderer)
controller.Layer, err = view.NewLayerView("layers", g, analysis.Layers)
if err != nil {
return nil, err
}
controller.lookup[controller.Layer.Name()] = controller.Layer
//treeStack, err := filetree.StackTreeRange(analysis.RefTrees, 0, 0)
//if err != nil {
// return nil, err
//}
treeStack := analysis.RefTrees[0]
controller.Tree, err = view.NewFileTreeView("filetree", g, treeStack, analysis.RefTrees, cache)
if err != nil {
return nil, err
}
controller.lookup[controller.Tree.Name()] = controller.Tree
// layer view cursor down event should trigger an update in the file tree
controller.Layer.AddLayerChangeListener(controller.onLayerChange)
controller.Status = view.NewStatusView("status", g)
controller.lookup[controller.Status.Name()] = controller.Status
// set the layer view as the first selected view
controller.Status.SetCurrentView(controller.Layer)
controller.views.Layer.AddLayerChangeListener(controller.onLayerChange)
// update the status pane when a filetree option is changed by the user
controller.Tree.AddViewOptionChangeListener(controller.onFileTreeViewOptionChange)
controller.views.Tree.AddViewOptionChangeListener(controller.onFileTreeViewOptionChange)
controller.Filter = view.NewFilterView("filter", g)
controller.lookup[controller.Filter.Name()] = controller.Filter
controller.Filter.AddFilterEditListener(controller.onFilterEdit)
controller.Details = view.NewDetailsView("details", g, analysis.Efficiency, analysis.Inefficiencies, analysis.SizeBytes)
controller.lookup[controller.Details.Name()] = controller.Details
// update the tree view while the user types into the filter view
controller.views.Filter.AddFilterEditListener(controller.onFilterEdit)
// propagate initial conditions to necessary views
err = controller.onLayerChange(viewmodel.LayerSelection{
Layer: controller.Layer.CurrentLayer(),
Layer: controller.views.Layer.CurrentLayer(),
BottomTreeStart: 0,
BottomTreeStop: 0,
TopTreeStart: 0,
@ -80,11 +52,11 @@ func NewCollection(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.
}
func (c *Controller) onFileTreeViewOptionChange() error {
err := c.Status.Update()
err := c.views.Status.Update()
if err != nil {
return err
}
return c.Status.Render()
return c.views.Status.Render()
}
func (c *Controller) onFilterEdit(filter string) error {
@ -98,30 +70,30 @@ func (c *Controller) onFilterEdit(filter string) error {
}
}
c.Tree.SetFilterRegex(filterRegex)
c.views.Tree.SetFilterRegex(filterRegex)
err = c.Tree.Update()
err = c.views.Tree.Update()
if err != nil {
return err
}
return c.Tree.Render()
return c.views.Tree.Render()
}
func (c *Controller) onLayerChange(selection viewmodel.LayerSelection) error {
// update the details
c.Details.SetCurrentLayer(selection.Layer)
c.views.Details.SetCurrentLayer(selection.Layer)
// update the filetree
err := c.Tree.SetTree(selection.BottomTreeStart, selection.BottomTreeStop, selection.TopTreeStart, selection.TopTreeStop)
err := c.views.Tree.SetTree(selection.BottomTreeStart, selection.BottomTreeStop, selection.TopTreeStart, selection.TopTreeStop)
if err != nil {
return err
}
if c.Layer.CompareMode == view.CompareAll {
c.Tree.SetTitle("Aggregated Layer Contents")
if c.views.Layer.CompareMode == view.CompareAll {
c.views.Tree.SetTitle("Aggregated Layer Contents")
} else {
c.Tree.SetTitle("Current Layer Contents")
c.views.Tree.SetTitle("Current Layer Contents")
}
// update details and filetree panes
@ -146,7 +118,7 @@ func (c *Controller) UpdateAndRender() error {
// Update refreshes the state objects for future rendering.
func (c *Controller) Update() error {
for _, controller := range c.lookup {
for _, controller := range c.views.All() {
err := controller.Update()
if err != nil {
logrus.Debug("unable to update controller: ")
@ -158,7 +130,7 @@ func (c *Controller) Update() error {
// Render flushes the state objects to the screen.
func (c *Controller) Render() error {
for _, controller := range c.lookup {
for _, controller := range c.views.All() {
if controller.IsVisible() {
err := controller.Render()
if err != nil {
@ -172,12 +144,12 @@ func (c *Controller) Render() error {
// ToggleView switches between the file view and the layer view and re-renders the screen.
func (c *Controller) ToggleView() (err error) {
v := c.gui.CurrentView()
if v == nil || v.Name() == c.Layer.Name() {
_, err = c.gui.SetCurrentView(c.Tree.Name())
c.Status.SetCurrentView(c.Tree)
if v == nil || v.Name() == c.views.Layer.Name() {
_, err = c.gui.SetCurrentView(c.views.Tree.Name())
c.views.Status.SetCurrentView(c.views.Tree)
} else {
_, err = c.gui.SetCurrentView(c.Layer.Name())
c.Status.SetCurrentView(c.Layer)
_, err = c.gui.SetCurrentView(c.views.Layer.Name())
c.views.Status.SetCurrentView(c.views.Layer)
}
if err != nil {
@ -190,16 +162,16 @@ func (c *Controller) ToggleView() (err error) {
func (c *Controller) ToggleFilterView() error {
// delete all user input from the tree view
err := c.Filter.ToggleVisible()
err := c.views.Filter.ToggleVisible()
if err != nil {
logrus.Error("unable to toggle filter visibility: ", err)
return err
}
// we have just hidden the filter view...
if !c.Filter.IsVisible() {
if !c.views.Filter.IsVisible() {
// ...remove any filter from the tree
c.Tree.SetFilterRegex(nil)
c.views.Tree.SetFilterRegex(nil)
// ...adjust focus to a valid (visible) view
err = c.ToggleView()

View file

@ -0,0 +1,96 @@
package compound
import (
"github.com/jroimartin/gocui"
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/runtime/ui/view"
"github.com/wagoodman/dive/utils"
)
type LayerDetailsCompoundLayout struct {
layer *view.Layer
details *view.Details
}
func NewLayerDetailsCompoundLayout(layer *view.Layer, details *view.Details) *LayerDetailsCompoundLayout {
return &LayerDetailsCompoundLayout{
layer: layer,
details: details,
}
}
func (cl *LayerDetailsCompoundLayout) Name() string {
return "layer-details-compound-column"
}
func (cl *LayerDetailsCompoundLayout) Layout(g *gocui.Gui, minX, minY, maxX, maxY int, hasResized bool) error {
logrus.Debugf("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, cl.Name())
////////////////////////////////////////////////////////////////////////////////////
// Layers View
// header + border
layerHeaderHeight := 2
layersHeight := len(cl.layer.Layers) + layerHeaderHeight + 1 // layers + header + base image layer row
maxLayerHeight := int(0.75 * float64(maxY))
if layersHeight > maxLayerHeight {
layersHeight = maxLayerHeight
}
// note: maxY needs to account for the (invisible) border, thus a +1
header, headerErr := g.SetView(cl.layer.Name()+"header", minX, minY, maxX, minY+layerHeaderHeight+1)
// we are going to overlap the view over the (invisible) border (so minY will be one less than expected)
main, viewErr := g.SetView(cl.layer.Name(), minX, minY+layerHeaderHeight, maxX, minY+layerHeaderHeight+layersHeight)
if utils.IsNewView(viewErr, headerErr) {
err := cl.layer.Setup(main, header)
if err != nil {
logrus.Error("unable to setup layer layout", err)
return err
}
if _, err = g.SetCurrentView(cl.layer.Name()); err != nil {
logrus.Error("unable to set view to layer", err)
return err
}
// since we are selecting the view, we should rerender to indicate it is selected
err = cl.layer.Render()
if err != nil {
logrus.Error("unable to render layer view", err)
return err
}
}
////////////////////////////////////////////////////////////////////////////////////
// Details
detailsMinY := minY + layersHeight
// header + border
detailsHeaderHeight := 2
// note: maxY needs to account for the (invisible) border, thus a +1
header, headerErr = g.SetView(cl.details.Name()+"header", minX, detailsMinY, maxX, detailsMinY+detailsHeaderHeight+1)
// we are going to overlap the view over the (invisible) border (so minY will be one less than expected)
// additionally, maxY will be bumped by one to include the border
main, viewErr = g.SetView(cl.details.Name(), minX, detailsMinY+detailsHeaderHeight, maxX, maxY+1)
if utils.IsNewView(viewErr, headerErr) {
err := cl.details.Setup(main, header)
if err != nil {
return err
}
}
return nil
}
func (cl *LayerDetailsCompoundLayout) RequestedSize(available int) *int {
return nil
}
// todo: make this variable based on the nested views
func (cl *LayerDetailsCompoundLayout) IsVisible() bool {
return true
}

View file

@ -0,0 +1,10 @@
package layout
import "github.com/jroimartin/gocui"
type Layout interface {
Name() string
Layout(g *gocui.Gui, minX, minY, maxX, maxY int, hasResized bool) error
RequestedSize(available int) *int
IsVisible() bool
}

View file

@ -0,0 +1,9 @@
package layout
const (
LocationFooter Location = iota
LocationHeader
LocationColumn
)
type Location int

View file

@ -0,0 +1,176 @@
package layout
import (
"github.com/jroimartin/gocui"
"github.com/sirupsen/logrus"
)
type Manager struct {
lastX, lastY int
elements map[Location][]Layout
}
func NewManager() *Manager {
return &Manager{
elements: make(map[Location][]Layout),
}
}
func (lm *Manager) Add(element Layout, location Location) {
if _, exists := lm.elements[location]; !exists {
lm.elements[location] = make([]Layout, 0)
}
lm.elements[location] = append(lm.elements[location], element)
}
// layout defines the definition of the window pane size and placement relations to one another. This
// is invoked at application start and whenever the screen dimensions change.
// A few things to note:
// 1. gocui has borders around all views (even if Frame=false). This means there are a lot of +1/-1 magic numbers
// needed (but there are comments!).
// 2. since there are borders, in order for it to appear as if there aren't any spaces for borders, the views must
// overlap. To prevent screen artifacts, all elements must be layedout from the top of the screen to the bottom.
func (lm *Manager) Layout(g *gocui.Gui) error {
minX, minY := -1, -1
maxX, maxY := g.Size()
var hasResized bool
if maxX != lm.lastX || maxY != lm.lastY {
hasResized = true
}
lm.lastX, lm.lastY = maxX, maxY
// layout headers top down
if elements, exists := lm.elements[LocationHeader]; exists {
for _, element := range elements {
// a visible header cannot take up the whole screen, default to 1.
// this eliminates the need to discover a default size based on all element requests
height := 0
if element.IsVisible() {
requestedHeight := element.RequestedSize(maxY)
if requestedHeight != nil {
height = *requestedHeight
} else {
height = 1
}
}
// layout the header within the allocated space
err := element.Layout(g, minX, minY, maxX, minY+height, hasResized)
if err != nil {
logrus.Errorf("failed to layout '%s' header: %+v", element.Name(), err)
}
// restrict the available screen real estate
minY += height
}
}
var footerHeights = make([]int, 0)
// we need to keep the current maxY before carving out the space for the body columns
var footerMaxY = maxY
var footerMinX = minX
var footerMaxX = maxX
// we need to layout the footers last, but account for them when drawing the columns. This block is for planning
// out the real estate needed for the footers now (but not laying out yet)
if elements, exists := lm.elements[LocationFooter]; exists {
footerHeights = make([]int, len(elements))
for idx := range footerHeights {
footerHeights[idx] = 1
}
for idx, element := range elements {
// a visible footer cannot take up the whole screen, default to 1.
// this eliminates the need to discover a default size based on all element requests
height := 0
if element.IsVisible() {
requestedHeight := element.RequestedSize(maxY)
if requestedHeight != nil {
height = *requestedHeight
} else {
height = 1
}
}
footerHeights[idx] = height
}
// restrict the available screen real estate
for _, height := range footerHeights {
maxY -= height
}
}
// layout columns left to right
if elements, exists := lm.elements[LocationColumn]; exists {
widths := make([]int, len(elements))
for idx := range widths {
widths[idx] = -1
}
variableColumns := len(elements)
availableWidth := maxX
// first pass: planout the column sizes based on the given requests
for idx, element := range elements {
if !element.IsVisible() {
widths[idx] = 0
variableColumns--
continue
}
requestedWidth := element.RequestedSize(availableWidth)
if requestedWidth != nil {
widths[idx] = *requestedWidth
variableColumns--
availableWidth -= widths[idx]
}
}
defaultWidth := int(availableWidth / variableColumns)
// second pass: layout columns left to right (based off predetermined widths)
for idx, element := range elements {
// use the requested or default width
width := widths[idx]
if width == -1 {
width = defaultWidth
}
// layout the column within the allocated space
err := element.Layout(g, minX, minY, minX+width, maxY, hasResized)
if err != nil {
logrus.Errorf("failed to layout '%s' column: %+v", element.Name(), err)
}
// move left to right, scratching off real estate as it is taken
minX += width
}
}
// layout footers top down (which is why the list is reversed). Top down is needed due to border overlap.
if elements, exists := lm.elements[LocationFooter]; exists {
for idx := len(elements) - 1; idx >= 0; idx-- {
element := elements[idx]
height := footerHeights[idx]
var topY, bottomY, bottomPadding int
for oIdx := 0; oIdx <= idx; oIdx++ {
bottomPadding += footerHeights[oIdx]
}
topY = footerMaxY - bottomPadding - height
// +1 for border
bottomY = topY + height + 1
// layout the footer within the allocated space
// note: since the headers and rows are inclusive counting from -1 (to account for a border) we must
// do the same vertically, thus a -1 is needed for a starting Y
err := element.Layout(g, footerMinX, topY, footerMaxX, bottomY, hasResized)
if err != nil {
logrus.Errorf("failed to layout '%s' footer: %+v", element.Name(), err)
}
}
}
return nil
}

View file

@ -1,169 +0,0 @@
package ui
import (
"github.com/jroimartin/gocui"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
type layoutManager struct {
fileTreeSplitRatio float64
controllers *Controller
}
// todo: this needs a major refactor (derive layout from view obj info, which should not live here)
func newLayoutManager(c *Controller) *layoutManager {
fileTreeSplitRatio := viper.GetFloat64("filetree.pane-width")
if fileTreeSplitRatio >= 1 || fileTreeSplitRatio <= 0 {
logrus.Errorf("invalid config value: 'filetree.pane-width' should be 0 < value < 1, given '%v'", fileTreeSplitRatio)
fileTreeSplitRatio = 0.5
}
return &layoutManager{
fileTreeSplitRatio: fileTreeSplitRatio,
controllers: c,
}
}
// IsNewView determines if a view has already been created based on the set of errors given (a bit hokie)
func IsNewView(errs ...error) bool {
for _, err := range errs {
if err == nil {
return false
}
if err != gocui.ErrUnknownView {
return false
}
}
return true
}
// layout defines the definition of the window pane size and placement relations to one another. This
// is invoked at application start and whenever the screen dimensions change.
func (lm *layoutManager) layout(g *gocui.Gui) error {
// TODO: this logic should be refactored into an abstraction that takes care of the math for us
maxX, maxY := g.Size()
var resized bool
if maxX != lastX {
resized = true
}
if maxY != lastY {
resized = true
}
lastX, lastY = maxX, maxY
splitCols := int(float64(maxX) * (1.0 - lm.fileTreeSplitRatio))
debugWidth := 0
if debug {
debugWidth = maxX / 4
}
debugCols := maxX - debugWidth
bottomRows := 1
headerRows := 2
filterBarHeight := 1
statusBarHeight := 1
statusBarIndex := 1
filterBarIndex := 2
layersHeight := len(lm.controllers.Layer.Layers) + headerRows + 1 // layers + header + base image layer row
maxLayerHeight := int(0.75 * float64(maxY))
if layersHeight > maxLayerHeight {
layersHeight = maxLayerHeight
}
var view, header *gocui.View
var viewErr, headerErr, err error
if !lm.controllers.Filter.IsVisible() {
bottomRows--
filterBarHeight = 0
}
// Debug pane
if debug {
if _, err := g.SetView("debug", debugCols, -1, maxX, maxY-bottomRows); err != nil {
if err != gocui.ErrUnknownView {
return err
}
}
}
// Layers
view, viewErr = g.SetView(lm.controllers.Layer.Name(), -1, -1+headerRows, splitCols, layersHeight)
header, headerErr = g.SetView(lm.controllers.Layer.Name()+"header", -1, -1, splitCols, headerRows)
if IsNewView(viewErr, headerErr) {
err = lm.controllers.Layer.Setup(view, header)
if err != nil {
logrus.Error("unable to setup layer controller", err)
return err
}
if _, err = g.SetCurrentView(lm.controllers.Layer.Name()); err != nil {
logrus.Error("unable to set view to layer", err)
return err
}
// since we are selecting the view, we should rerender to indicate it is selected
err = lm.controllers.Layer.Render()
if err != nil {
logrus.Error("unable to render layer view", err)
return err
}
}
// Details
view, viewErr = g.SetView(lm.controllers.Details.Name(), -1, -1+layersHeight+headerRows, splitCols, maxY-bottomRows)
header, headerErr = g.SetView(lm.controllers.Details.Name()+"header", -1, -1+layersHeight, splitCols, layersHeight+headerRows)
if IsNewView(viewErr, headerErr) {
err = lm.controllers.Details.Setup(view, header)
if err != nil {
return err
}
}
// Filetree
offset := 0
if !lm.controllers.Tree.AreAttributesVisible() {
offset = 1
}
view, viewErr = g.SetView(lm.controllers.Tree.Name(), splitCols, -1+headerRows-offset, debugCols, maxY-bottomRows)
header, headerErr = g.SetView(lm.controllers.Tree.Name()+"header", splitCols, -1, debugCols, headerRows-offset)
if IsNewView(viewErr, headerErr) {
err = lm.controllers.Tree.Setup(view, header)
if err != nil {
logrus.Error("unable to setup tree controller", err)
return err
}
}
err = lm.controllers.Tree.OnLayoutChange(resized)
if err != nil {
logrus.Error("unable to setup layer controller onLayoutChange", err)
return err
}
// Status Bar
view, viewErr = g.SetView(lm.controllers.Status.Name(), -1, maxY-statusBarHeight-statusBarIndex, maxX, maxY-(statusBarIndex-1))
if IsNewView(viewErr, headerErr) {
err = lm.controllers.Status.Setup(view, nil)
if err != nil {
logrus.Error("unable to setup status controller", err)
return err
}
}
// Filter Bar
view, viewErr = g.SetView(lm.controllers.Filter.Name(), len(lm.controllers.Filter.HeaderStr())-1, maxY-filterBarHeight-filterBarIndex, maxX, maxY-(filterBarIndex-1))
header, headerErr = g.SetView(lm.controllers.Filter.Name()+"header", -1, maxY-filterBarHeight-filterBarIndex, len(lm.controllers.Filter.HeaderStr()), maxY-(filterBarIndex-1))
if IsNewView(viewErr, headerErr) {
err = lm.controllers.Filter.Setup(view, header)
if err != nil {
logrus.Error("unable to setup filter controller", err)
return err
}
}
return nil
}

View file

@ -29,8 +29,8 @@ type Details struct {
currentLayer *image.Layer
}
// 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, imageSize uint64) (controller *Details) {
// 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, imageSize uint64) (controller *Details) {
controller = new(Details)
// populate main fields
@ -43,68 +43,69 @@ func NewDetailsView(name string, gui *gocui.Gui, efficiency float64, inefficienc
return controller
}
func (c *Details) Name() string {
return c.name
func (v *Details) Name() string {
return v.name
}
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (c *Details) Setup(v *gocui.View, header *gocui.View) error {
func (v *Details) Setup(view *gocui.View, header *gocui.View) error {
logrus.Debugf("view.Setup() %s", v.Name())
// set controller options
c.view = v
c.view.Editable = false
c.view.Wrap = true
c.view.Highlight = false
c.view.Frame = false
v.view = view
v.view.Editable = false
v.view.Wrap = true
v.view.Highlight = false
v.view.Frame = false
c.header = header
c.header.Editable = false
c.header.Wrap = false
c.header.Frame = false
v.header = header
v.header.Editable = false
v.header.Wrap = false
v.header.Frame = false
var infos = []key.BindingInfo{
{
Key: gocui.KeyArrowDown,
Modifier: gocui.ModNone,
OnAction: c.CursorDown,
OnAction: v.CursorDown,
},
{
Key: gocui.KeyArrowUp,
Modifier: gocui.ModNone,
OnAction: c.CursorUp,
OnAction: v.CursorUp,
},
}
_, err := key.GenerateBindings(c.gui, c.name, infos)
_, err := key.GenerateBindings(v.gui, v.name, infos)
if err != nil {
return err
}
return c.Render()
return v.Render()
}
// IsVisible indicates if the details view pane is currently initialized.
func (c *Details) IsVisible() bool {
return c != nil
func (v *Details) IsVisible() bool {
return v != nil
}
// CursorDown moves the cursor down in the details pane (currently indicates nothing).
func (c *Details) CursorDown() error {
return CursorDown(c.gui, c.view)
func (v *Details) CursorDown() error {
return CursorDown(v.gui, v.view)
}
// CursorUp moves the cursor up in the details pane (currently indicates nothing).
func (c *Details) CursorUp() error {
return CursorUp(c.gui, c.view)
func (v *Details) CursorUp() error {
return CursorUp(v.gui, v.view)
}
// Update refreshes the state objects for future rendering.
func (c *Details) Update() error {
func (v *Details) Update() error {
return nil
}
func (c *Details) SetCurrentLayer(layer *image.Layer) {
c.currentLayer = layer
func (v *Details) SetCurrentLayer(layer *image.Layer) {
v.currentLayer = layer
}
// Render flushes the state objects to the screen. The details pane reports:
@ -112,8 +113,10 @@ func (c *Details) SetCurrentLayer(layer *image.Layer) {
// 2. the image efficiency score
// 3. the estimated wasted image space
// 4. a list of inefficient file allocations
func (c *Details) Render() error {
if c.currentLayer == nil {
func (v *Details) Render() error {
logrus.Debugf("view.Render() %s", v.Name())
if v.currentLayer == nil {
return fmt.Errorf("no layer selected")
}
@ -123,12 +126,12 @@ func (c *Details) Render() error {
inefficiencyReport := fmt.Sprintf(format.Header(template), "Count", "Total Space", "Path")
height := 100
if c.view != nil {
_, height = c.view.Size()
if v.view != nil {
_, height = v.view.Size()
}
for idx := 0; idx < len(c.inefficiencies); idx++ {
data := c.inefficiencies[len(c.inefficiencies)-1-idx]
for idx := 0; idx < len(v.inefficiencies); idx++ {
data := v.inefficiencies[len(v.inefficiencies)-1-idx]
wastedSpace += data.CumulativeSize
// todo: make this report scrollable
@ -137,43 +140,43 @@ func (c *Details) Render() error {
}
}
imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(c.imageSize))
effStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*c.efficiency))
imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(v.imageSize))
effStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*v.efficiency))
wastedSpaceStr := fmt.Sprintf("%s %s", format.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace)))
c.gui.Update(func(g *gocui.Gui) error {
v.gui.Update(func(g *gocui.Gui) error {
// update header
c.header.Clear()
width, _ := c.view.Size()
v.header.Clear()
width, _ := v.view.Size()
layerHeaderStr := fmt.Sprintf("[Layer Details]%s", strings.Repeat("─", width-15))
imageHeaderStr := fmt.Sprintf("[Image Details]%s", strings.Repeat("─", width-15))
_, err := fmt.Fprintln(c.header, format.Header(vtclean.Clean(layerHeaderStr, false)))
_, err := fmt.Fprintln(v.header, format.Header(vtclean.Clean(layerHeaderStr, false)))
if err != nil {
return err
}
// update contents
c.view.Clear()
v.view.Clear()
var lines = make([]string, 0)
if c.currentLayer.Names != nil && len(c.currentLayer.Names) > 0 {
lines = append(lines, format.Header("Tags: ")+strings.Join(c.currentLayer.Names, ", "))
if v.currentLayer.Names != nil && len(v.currentLayer.Names) > 0 {
lines = append(lines, format.Header("Tags: ")+strings.Join(v.currentLayer.Names, ", "))
} else {
lines = append(lines, format.Header("Tags: ")+"(none)")
}
lines = append(lines, format.Header("Id: ")+c.currentLayer.Id)
lines = append(lines, format.Header("Digest: ")+c.currentLayer.Digest)
lines = append(lines, format.Header("Id: ")+v.currentLayer.Id)
lines = append(lines, format.Header("Digest: ")+v.currentLayer.Digest)
lines = append(lines, format.Header("Command:"))
lines = append(lines, c.currentLayer.Command)
lines = append(lines, v.currentLayer.Command)
lines = append(lines, "\n"+format.Header(vtclean.Clean(imageHeaderStr, false)))
lines = append(lines, imageSizeStr)
lines = append(lines, wastedSpaceStr)
lines = append(lines, effStr+"\n")
lines = append(lines, inefficiencyReport)
_, err = fmt.Fprintln(c.view, strings.Join(lines, "\n"))
_, err = fmt.Fprintln(v.view, strings.Join(lines, "\n"))
if err != nil {
logrus.Debug("unable to write to buffer: ", err)
}
@ -183,6 +186,6 @@ func (c *Details) Render() error {
}
// KeyHelp indicates all the possible actions a user can take while the current pane is selected (currently does nothing).
func (c *Details) KeyHelp() string {
func (v *Details) KeyHelp() string {
return "TBD"
}

View file

@ -3,9 +3,11 @@ package view
import (
"fmt"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/runtime/ui/key"
"github.com/wagoodman/dive/runtime/ui/viewmodel"
"github.com/wagoodman/dive/utils"
"regexp"
"strings"
@ -33,15 +35,14 @@ type FileTree struct {
vm *viewmodel.FileTree
title string
filterRegex *regexp.Regexp
listeners []ViewOptionChangeListener
helpKeys []*key.Binding
filterRegex *regexp.Regexp
listeners []ViewOptionChangeListener
helpKeys []*key.Binding
requestedWidthRatio float64
}
// 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.Comparer) (controller *FileTree, err error) {
// 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.Comparer) (controller *FileTree, err error) {
controller = new(FileTree)
controller.listeners = make([]ViewOptionChangeListener, 0)
@ -53,157 +54,165 @@ func NewFileTreeView(name string, gui *gocui.Gui, tree *filetree.FileTree, refTr
return nil, err
}
requestedWidthRatio := viper.GetFloat64("filetree.pane-width")
if requestedWidthRatio >= 1 || requestedWidthRatio <= 0 {
logrus.Errorf("invalid config value: 'filetree.pane-width' should be 0 < value < 1, given '%v'", requestedWidthRatio)
requestedWidthRatio = 0.5
}
controller.requestedWidthRatio = requestedWidthRatio
return controller, err
}
func (c *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListener) {
c.listeners = append(c.listeners, listener...)
func (v *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListener) {
v.listeners = append(v.listeners, listener...)
}
func (c *FileTree) SetTitle(title string) {
c.title = title
func (v *FileTree) SetTitle(title string) {
v.title = title
}
func (c *FileTree) SetFilterRegex(filterRegex *regexp.Regexp) {
c.filterRegex = filterRegex
func (v *FileTree) SetFilterRegex(filterRegex *regexp.Regexp) {
v.filterRegex = filterRegex
}
func (c *FileTree) Name() string {
return c.name
func (v *FileTree) Name() string {
return v.name
}
func (c *FileTree) AreAttributesVisible() bool {
return c.vm.ShowAttributes
func (v *FileTree) areAttributesVisible() bool {
return v.vm.ShowAttributes
}
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (c *FileTree) Setup(v *gocui.View, header *gocui.View) error {
func (v *FileTree) Setup(view *gocui.View, header *gocui.View) error {
logrus.Debugf("view.Setup() %s", v.Name())
// set controller options
c.view = v
c.view.Editable = false
c.view.Wrap = false
c.view.Frame = false
v.view = view
v.view.Editable = false
v.view.Wrap = false
v.view.Frame = false
c.header = header
c.header.Editable = false
c.header.Wrap = false
c.header.Frame = false
v.header = header
v.header.Editable = false
v.header.Wrap = false
v.header.Frame = false
var infos = []key.BindingInfo{
{
ConfigKeys: []string{"keybinding.toggle-collapse-dir"},
OnAction: c.toggleCollapse,
OnAction: v.toggleCollapse,
Display: "Collapse dir",
},
{
ConfigKeys: []string{"keybinding.toggle-collapse-all-dir"},
OnAction: c.toggleCollapseAll,
OnAction: v.toggleCollapseAll,
Display: "Collapse all dir",
},
{
ConfigKeys: []string{"keybinding.toggle-added-files"},
OnAction: func() error { return c.toggleShowDiffType(filetree.Added) },
IsSelected: func() bool { return !c.vm.HiddenDiffTypes[filetree.Added] },
OnAction: func() error { return v.toggleShowDiffType(filetree.Added) },
IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Added] },
Display: "Added",
},
{
ConfigKeys: []string{"keybinding.toggle-removed-files"},
OnAction: func() error { return c.toggleShowDiffType(filetree.Removed) },
IsSelected: func() bool { return !c.vm.HiddenDiffTypes[filetree.Removed] },
OnAction: func() error { return v.toggleShowDiffType(filetree.Removed) },
IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Removed] },
Display: "Removed",
},
{
ConfigKeys: []string{"keybinding.toggle-modified-files"},
OnAction: func() error { return c.toggleShowDiffType(filetree.Modified) },
IsSelected: func() bool { return !c.vm.HiddenDiffTypes[filetree.Modified] },
OnAction: func() error { return v.toggleShowDiffType(filetree.Modified) },
IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Modified] },
Display: "Modified",
},
{
ConfigKeys: []string{"keybinding.toggle-unchanged-files", "keybinding.toggle-unmodified-files"},
OnAction: func() error { return c.toggleShowDiffType(filetree.Unmodified) },
IsSelected: func() bool { return !c.vm.HiddenDiffTypes[filetree.Unmodified] },
OnAction: func() error { return v.toggleShowDiffType(filetree.Unmodified) },
IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Unmodified] },
Display: "Unmodified",
},
{
ConfigKeys: []string{"keybinding.toggle-filetree-attributes"},
OnAction: c.toggleAttributes,
IsSelected: func() bool { return c.vm.ShowAttributes },
OnAction: v.toggleAttributes,
IsSelected: func() bool { return v.vm.ShowAttributes },
Display: "Attributes",
},
{
ConfigKeys: []string{"keybinding.page-up"},
OnAction: c.PageUp,
OnAction: v.PageUp,
},
{
ConfigKeys: []string{"keybinding.page-down"},
OnAction: c.PageDown,
OnAction: v.PageDown,
},
{
Key: gocui.KeyArrowDown,
Modifier: gocui.ModNone,
OnAction: c.CursorDown,
OnAction: v.CursorDown,
},
{
Key: gocui.KeyArrowUp,
Modifier: gocui.ModNone,
OnAction: c.CursorUp,
OnAction: v.CursorUp,
},
{
Key: gocui.KeyArrowLeft,
Modifier: gocui.ModNone,
OnAction: c.CursorLeft,
OnAction: v.CursorLeft,
},
{
Key: gocui.KeyArrowRight,
Modifier: gocui.ModNone,
OnAction: c.CursorRight,
OnAction: v.CursorRight,
},
}
helpKeys, err := key.GenerateBindings(c.gui, c.name, infos)
helpKeys, err := key.GenerateBindings(v.gui, v.name, infos)
if err != nil {
return err
}
c.helpKeys = helpKeys
v.helpKeys = helpKeys
_, height := c.view.Size()
c.vm.Setup(0, height)
_ = c.Update()
_ = c.Render()
_, height := v.view.Size()
v.vm.Setup(0, height)
_ = v.Update()
_ = v.Render()
return nil
}
// IsVisible indicates if the file tree view pane is currently initialized
func (c *FileTree) IsVisible() bool {
return c != nil
func (v *FileTree) IsVisible() bool {
return v != nil
}
// ResetCursor moves the cursor back to the top of the buffer and translates to the top of the buffer.
func (c *FileTree) resetCursor() {
_ = c.view.SetCursor(0, 0)
c.vm.ResetCursor()
func (v *FileTree) resetCursor() {
_ = v.view.SetCursor(0, 0)
v.vm.ResetCursor()
}
// SetTreeByLayer populates the view model by stacking the indicated image layer file trees.
func (c *FileTree) SetTree(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error {
err := c.vm.SetTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop)
func (v *FileTree) SetTree(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error {
err := v.vm.SetTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop)
if err != nil {
return err
}
_ = c.Update()
return c.Render()
_ = v.Update()
return v.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 (c *FileTree) CursorDown() error {
if c.vm.CursorDown() {
return c.Render()
func (v *FileTree) CursorDown() error {
if v.vm.CursorDown() {
return v.Render()
}
return nil
}
@ -212,49 +221,49 @@ func (c *FileTree) CursorDown() error {
// 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 (c *FileTree) CursorUp() error {
if c.vm.CursorUp() {
return c.Render()
func (v *FileTree) CursorUp() error {
if v.vm.CursorUp() {
return v.Render()
}
return nil
}
// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree
func (c *FileTree) CursorLeft() error {
err := c.vm.CursorLeft(c.filterRegex)
func (v *FileTree) CursorLeft() error {
err := v.vm.CursorLeft(v.filterRegex)
if err != nil {
return err
}
_ = c.Update()
return c.Render()
_ = v.Update()
return v.Render()
}
// CursorRight descends into directory expanding it if needed
func (c *FileTree) CursorRight() error {
err := c.vm.CursorRight(c.filterRegex)
func (v *FileTree) CursorRight() error {
err := v.vm.CursorRight(v.filterRegex)
if err != nil {
return err
}
_ = c.Update()
return c.Render()
_ = v.Update()
return v.Render()
}
// PageDown moves to next page putting the cursor on top
func (c *FileTree) PageDown() error {
err := c.vm.PageDown()
func (v *FileTree) PageDown() error {
err := v.vm.PageDown()
if err != nil {
return err
}
return c.Render()
return v.Render()
}
// PageUp moves to previous page putting the cursor on top
func (c *FileTree) PageUp() error {
err := c.vm.PageUp()
func (v *FileTree) PageUp() error {
err := v.vm.PageUp()
if err != nil {
return err
}
return c.Render()
return v.Render()
}
// getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode.
@ -263,30 +272,30 @@ func (c *FileTree) PageUp() error {
// }
// ToggleCollapse will collapse/expand the selected FileNode.
func (c *FileTree) toggleCollapse() error {
err := c.vm.ToggleCollapse(c.filterRegex)
func (v *FileTree) toggleCollapse() error {
err := v.vm.ToggleCollapse(v.filterRegex)
if err != nil {
return err
}
_ = c.Update()
return c.Render()
_ = v.Update()
return v.Render()
}
// ToggleCollapseAll will collapse/expand the all directories.
func (c *FileTree) toggleCollapseAll() error {
err := c.vm.ToggleCollapseAll()
func (v *FileTree) toggleCollapseAll() error {
err := v.vm.ToggleCollapseAll()
if err != nil {
return err
}
if c.vm.CollapseAll {
c.resetCursor()
if v.vm.CollapseAll {
v.resetCursor()
}
_ = c.Update()
return c.Render()
_ = v.Update()
return v.Render()
}
func (c *FileTree) notifyOnViewOptionChangeListeners() error {
for _, listener := range c.listeners {
func (v *FileTree) notifyOnViewOptionChangeListeners() error {
for _, listener := range v.listeners {
err := listener()
if err != nil {
logrus.Errorf("notifyOnViewOptionChangeListeners error: %+v", err)
@ -297,95 +306,97 @@ func (c *FileTree) notifyOnViewOptionChangeListeners() error {
}
// ToggleAttributes will show/hide file attributes
func (c *FileTree) toggleAttributes() error {
err := c.vm.ToggleAttributes()
func (v *FileTree) toggleAttributes() error {
err := v.vm.ToggleAttributes()
if err != nil {
return err
}
err = c.Update()
err = v.Update()
if err != nil {
return err
}
err = c.Render()
err = v.Render()
if err != nil {
return err
}
// we need to render the changes to the status pane as well (not just this contoller/view)
return c.notifyOnViewOptionChangeListeners()
return v.notifyOnViewOptionChangeListeners()
}
// ToggleShowDiffType will show/hide the selected DiffType in the filetree pane.
func (c *FileTree) toggleShowDiffType(diffType filetree.DiffType) error {
c.vm.ToggleShowDiffType(diffType)
func (v *FileTree) toggleShowDiffType(diffType filetree.DiffType) error {
v.vm.ToggleShowDiffType(diffType)
err := c.Update()
err := v.Update()
if err != nil {
return err
}
err = c.Render()
err = v.Render()
if err != nil {
return err
}
// we need to render the changes to the status pane as well (not just this contoller/view)
return c.notifyOnViewOptionChangeListeners()
return v.notifyOnViewOptionChangeListeners()
}
// OnLayoutChange is called by the UI framework to inform the view-model of the new screen dimensions
func (c *FileTree) OnLayoutChange(resized bool) error {
err := c.Update()
func (v *FileTree) OnLayoutChange(resized bool) error {
err := v.Update()
if err != nil {
return err
}
if resized {
return c.Render()
return v.Render()
}
return nil
}
// Update refreshes the state objects for future rendering.
func (c *FileTree) Update() error {
func (v *FileTree) Update() error {
var width, height int
if c.view != nil {
width, height = c.view.Size()
if v.view != nil {
width, height = v.view.Size()
} else {
// before the TUI is setup there may not be a controller to reference. Use the entire screen as reference.
width, height = c.gui.Size()
width, height = v.gui.Size()
}
// height should account for the header
return c.vm.Update(c.filterRegex, width, height-1)
return v.vm.Update(v.filterRegex, width, height-1)
}
// Render flushes the state objects (file tree) to the pane.
func (c *FileTree) Render() error {
title := c.title
func (v *FileTree) Render() error {
logrus.Debugf("view.Render() %s", v.Name())
title := v.title
// indicate when selected
if c.gui.CurrentView() == c.view {
title = "● " + c.title
if v.gui.CurrentView() == v.view {
title = "● " + v.title
}
c.gui.Update(func(g *gocui.Gui) error {
v.gui.Update(func(g *gocui.Gui) error {
// update the header
c.header.Clear()
v.header.Clear()
width, _ := g.Size()
headerStr := fmt.Sprintf("[%s]%s\n", title, strings.Repeat("─", width*2))
if c.vm.ShowAttributes {
if v.vm.ShowAttributes {
headerStr += fmt.Sprintf(filetree.AttributeFormat+" %s", "P", "ermission", "UID:GID", "Size", "Filetree")
}
_, _ = fmt.Fprintln(c.header, format.Header(vtclean.Clean(headerStr, false)))
_, _ = fmt.Fprintln(v.header, format.Header(vtclean.Clean(headerStr, false)))
// update the contents
c.view.Clear()
err := c.vm.Render()
v.view.Clear()
err := v.vm.Render()
if err != nil {
return err
}
_, err = fmt.Fprint(c.view, c.vm.Buffer.String())
_, err = fmt.Fprint(v.view, v.vm.Buffer.String())
return err
})
@ -393,10 +404,43 @@ func (c *FileTree) Render() error {
}
// KeyHelp indicates all the possible actions a user can take while the current pane is selected.
func (c *FileTree) KeyHelp() string {
func (v *FileTree) KeyHelp() string {
var help string
for _, binding := range c.helpKeys {
for _, binding := range v.helpKeys {
help += binding.RenderKeyHelp()
}
return help
}
func (v *FileTree) Layout(g *gocui.Gui, minX, minY, maxX, maxY int, hasResized bool) error {
logrus.Debugf("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name())
attributeRowSize := 0
if !v.areAttributesVisible() {
attributeRowSize = 1
}
// header + attribute + border
headerSize := 1 + attributeRowSize + 1
// note: maxY needs to account for the (invisible) border, thus a +1
header, headerErr := g.SetView(v.Name()+"header", minX, minY, maxX, minY+headerSize+1)
// we are going to overlap the view over the (invisible) border (so minY will be one less than expected).
// additionally, maxY will be bumped by one to include the border
view, viewErr := g.SetView(v.Name(), minX, minY+headerSize, maxX, maxY+1)
if utils.IsNewView(viewErr, headerErr) {
err := v.Setup(view, header)
if err != nil {
logrus.Error("unable to setup tree controller", err)
return err
}
}
err := v.OnLayoutChange(hasResized)
if err != nil {
logrus.Error("unable to setup layer controller onLayoutChange", err)
return err
}
return nil
}
func (v *FileTree) RequestedSize(available int) *int {
var requestedWidth = int(float64(available) * (1.0 - v.requestedWidthRatio))
return &requestedWidth
}

View file

@ -5,6 +5,7 @@ import (
"github.com/jroimartin/gocui"
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/utils"
"strings"
)
@ -13,19 +14,20 @@ type FilterEditListener func(string) error
// Filter holds the UI objects and data models for populating the bottom row. Specifically the pane that
// allows the user to filter the file tree by path.
type Filter struct {
name string
gui *gocui.Gui
view *gocui.View
header *gocui.View
headerStr string
maxLength int
hidden bool
name string
gui *gocui.Gui
view *gocui.View
header *gocui.View
labelStr string
maxLength int
hidden bool
requestedHeight int
filterEditListeners []FilterEditListener
}
// NewFilterView creates a new view object attached the the global [gocui] screen object.
func NewFilterView(name string, gui *gocui.Gui) (controller *Filter) {
// newFilterView creates a new view object attached the the global [gocui] screen object.
func newFilterView(name string, gui *gocui.Gui) (controller *Filter) {
controller = new(Filter)
controller.filterEditListeners = make([]FilterEditListener, 0)
@ -33,50 +35,53 @@ func NewFilterView(name string, gui *gocui.Gui) (controller *Filter) {
// populate main fields
controller.name = name
controller.gui = gui
controller.headerStr = "Path Filter: "
controller.labelStr = "Path Filter: "
controller.hidden = true
controller.requestedHeight = 1
return controller
}
func (c *Filter) AddFilterEditListener(listener ...FilterEditListener) {
c.filterEditListeners = append(c.filterEditListeners, listener...)
func (v *Filter) AddFilterEditListener(listener ...FilterEditListener) {
v.filterEditListeners = append(v.filterEditListeners, listener...)
}
func (c *Filter) Name() string {
return c.name
func (v *Filter) Name() string {
return v.name
}
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (c *Filter) Setup(v *gocui.View, header *gocui.View) error {
func (v *Filter) Setup(view *gocui.View, header *gocui.View) error {
logrus.Debugf("view.Setup() %s", v.Name())
// set controller options
c.view = v
c.maxLength = 200
c.view.Frame = false
c.view.BgColor = gocui.AttrReverse
c.view.Editable = true
c.view.Editor = c
v.view = view
v.maxLength = 200
v.view.Frame = false
v.view.BgColor = gocui.AttrReverse
v.view.Editable = true
v.view.Editor = v
c.header = header
c.header.BgColor = gocui.AttrReverse
c.header.Editable = false
c.header.Wrap = false
c.header.Frame = false
v.header = header
v.header.BgColor = gocui.AttrReverse
v.header.Editable = false
v.header.Wrap = false
v.header.Frame = false
return c.Render()
return v.Render()
}
// ToggleFilterView shows/hides the file tree filter pane.
func (c *Filter) ToggleVisible() error {
func (v *Filter) ToggleVisible() error {
// delete all user input from the tree view
c.view.Clear()
v.view.Clear()
// toggle hiding
c.hidden = !c.hidden
v.hidden = !v.hidden
if !c.hidden {
_, err := c.gui.SetCurrentView(c.name)
if !v.hidden {
_, err := v.gui.SetCurrentView(v.name)
if err != nil {
logrus.Error("unable to toggle filter view: ", err)
return err
@ -87,57 +92,52 @@ func (c *Filter) ToggleVisible() error {
// reset the cursor for the next time it is visible
// Note: there is a subtle gocui behavior here where this cannot be called when the view
// is newly visible. Is this a problem with dive or gocui?
return c.view.SetCursor(0, 0)
}
// todo: remove the need for this
func (c *Filter) HeaderStr() string {
return c.headerStr
return v.view.SetCursor(0, 0)
}
// IsVisible indicates if the filter view pane is currently initialized
func (c *Filter) IsVisible() bool {
if c == nil {
func (v *Filter) IsVisible() bool {
if v == nil {
return false
}
return !c.hidden
return !v.hidden
}
// CursorDown moves the cursor down in the filter pane (currently indicates nothing).
func (c *Filter) CursorDown() error {
func (v *Filter) CursorDown() error {
return nil
}
// CursorUp moves the cursor up in the filter pane (currently indicates nothing).
func (c *Filter) CursorUp() error {
func (v *Filter) CursorUp() error {
return nil
}
// Edit intercepts the key press events in the filer view to update the file view in real time.
func (c *Filter) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) {
if !c.IsVisible() {
func (v *Filter) Edit(view *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) {
if !v.IsVisible() {
return
}
cx, _ := v.Cursor()
ox, _ := v.Origin()
limit := ox+cx+1 > c.maxLength
cx, _ := view.Cursor()
ox, _ := view.Origin()
limit := ox+cx+1 > v.maxLength
switch {
case ch != 0 && mod == 0 && !limit:
v.EditWrite(ch)
view.EditWrite(ch)
case key == gocui.KeySpace && !limit:
v.EditWrite(' ')
view.EditWrite(' ')
case key == gocui.KeyBackspace || key == gocui.KeyBackspace2:
v.EditDelete(true)
view.EditDelete(true)
}
// notify listeners
c.notifyFilterEditListeners()
v.notifyFilterEditListeners()
}
func (c *Filter) notifyFilterEditListeners() {
currentValue := strings.TrimSpace(c.view.Buffer())
for _, listener := range c.filterEditListeners {
func (v *Filter) notifyFilterEditListeners() {
currentValue := strings.TrimSpace(v.view.Buffer())
for _, listener := range v.filterEditListeners {
err := listener(currentValue)
if err != nil {
// note: cannot propagate error from here since this is from the main gogui thread
@ -147,14 +147,16 @@ func (c *Filter) notifyFilterEditListeners() {
}
// Update refreshes the state objects for future rendering (currently does nothing).
func (c *Filter) Update() error {
func (v *Filter) Update() error {
return nil
}
// Render flushes the state objects to the screen. Currently this is the users path filter input.
func (c *Filter) Render() error {
c.gui.Update(func(g *gocui.Gui) error {
_, err := fmt.Fprintln(c.header, format.Header(c.headerStr))
func (v *Filter) Render() error {
logrus.Debugf("view.Render() %s", v.Name())
v.gui.Update(func(g *gocui.Gui) error {
_, err := fmt.Fprintln(v.header, format.Header(v.labelStr))
if err != nil {
logrus.Error("unable to write to buffer: ", err)
}
@ -164,6 +166,26 @@ func (c *Filter) Render() error {
}
// KeyHelp indicates all the possible actions a user can take while the current pane is selected.
func (c *Filter) KeyHelp() string {
func (v *Filter) KeyHelp() string {
return format.StatusControlNormal("▏Type to filter the file tree ")
}
func (v *Filter) Layout(g *gocui.Gui, minX, minY, maxX, maxY int, hasResized bool) error {
logrus.Debugf("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name())
label, labelErr := g.SetView(v.Name()+"label", minX, minY, len(v.labelStr), maxY)
view, viewErr := g.SetView(v.Name(), minX+(len(v.labelStr)-1), minY, maxX, maxY)
if utils.IsNewView(viewErr, labelErr) {
err := v.Setup(view, label)
if err != nil {
logrus.Error("unable to setup status controller", err)
return err
}
}
return nil
}
func (v *Filter) RequestedSize(available int) *int {
return &v.requestedHeight
}

View file

@ -33,8 +33,8 @@ type Layer struct {
helpKeys []*key.Binding
}
// NewLayerView creates a new view object attached the the global [gocui] screen object.
func NewLayerView(name string, gui *gocui.Gui, layers []*image.Layer) (controller *Layer, err error) {
// newLayerView creates a new view object attached the the global [gocui] screen object.
func newLayerView(name string, gui *gocui.Gui, layers []*image.Layer) (controller *Layer, err error) {
controller = new(Layer)
controller.listeners = make([]LayerChangeListener, 0)
@ -56,20 +56,20 @@ func NewLayerView(name string, gui *gocui.Gui, layers []*image.Layer) (controlle
return controller, err
}
func (c *Layer) AddLayerChangeListener(listener ...LayerChangeListener) {
c.listeners = append(c.listeners, listener...)
func (v *Layer) AddLayerChangeListener(listener ...LayerChangeListener) {
v.listeners = append(v.listeners, listener...)
}
func (c *Layer) notifyLayerChangeListeners() error {
bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := c.getCompareIndexes()
func (v *Layer) notifyLayerChangeListeners() error {
bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.getCompareIndexes()
selection := viewmodel.LayerSelection{
Layer: c.CurrentLayer(),
Layer: v.CurrentLayer(),
BottomTreeStart: bottomTreeStart,
BottomTreeStop: bottomTreeStop,
TopTreeStart: topTreeStart,
TopTreeStop: topTreeStop,
}
for _, listener := range c.listeners {
for _, listener := range v.listeners {
err := listener(selection)
if err != nil {
logrus.Errorf("notifyLayerChangeListeners error: %+v", err)
@ -79,189 +79,190 @@ func (c *Layer) notifyLayerChangeListeners() error {
return nil
}
func (c *Layer) Name() string {
return c.name
func (v *Layer) Name() string {
return v.name
}
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (c *Layer) Setup(v *gocui.View, header *gocui.View) error {
func (v *Layer) Setup(view *gocui.View, header *gocui.View) error {
logrus.Debugf("view.Setup() %s", v.Name())
// set controller options
c.view = v
c.view.Editable = false
c.view.Wrap = false
c.view.Frame = false
v.view = view
v.view.Editable = false
v.view.Wrap = false
v.view.Frame = false
c.header = header
c.header.Editable = false
c.header.Wrap = false
c.header.Frame = false
v.header = header
v.header.Editable = false
v.header.Wrap = false
v.header.Frame = false
var infos = []key.BindingInfo{
{
ConfigKeys: []string{"keybinding.compare-layer"},
OnAction: func() error { return c.setCompareMode(CompareLayer) },
IsSelected: func() bool { return c.CompareMode == CompareLayer },
OnAction: func() error { return v.setCompareMode(CompareLayer) },
IsSelected: func() bool { return v.CompareMode == CompareLayer },
Display: "Show layer changes",
},
{
ConfigKeys: []string{"keybinding.compare-all"},
OnAction: func() error { return c.setCompareMode(CompareAll) },
IsSelected: func() bool { return c.CompareMode == CompareAll },
OnAction: func() error { return v.setCompareMode(CompareAll) },
IsSelected: func() bool { return v.CompareMode == CompareAll },
Display: "Show aggregated changes",
},
{
Key: gocui.KeyArrowDown,
Modifier: gocui.ModNone,
OnAction: c.CursorDown,
OnAction: v.CursorDown,
},
{
Key: gocui.KeyArrowUp,
Modifier: gocui.ModNone,
OnAction: c.CursorUp,
OnAction: v.CursorUp,
},
{
Key: gocui.KeyArrowLeft,
Modifier: gocui.ModNone,
OnAction: c.CursorUp,
OnAction: v.CursorUp,
},
{
Key: gocui.KeyArrowRight,
Modifier: gocui.ModNone,
OnAction: c.CursorDown,
OnAction: v.CursorDown,
},
{
ConfigKeys: []string{"keybinding.page-up"},
OnAction: c.PageUp,
OnAction: v.PageUp,
},
{
ConfigKeys: []string{"keybinding.page-down"},
OnAction: c.PageDown,
OnAction: v.PageDown,
},
}
helpKeys, err := key.GenerateBindings(c.gui, c.name, infos)
helpKeys, err := key.GenerateBindings(v.gui, v.name, infos)
if err != nil {
return err
}
c.helpKeys = helpKeys
v.helpKeys = helpKeys
return c.Render()
return v.Render()
}
// height obtains the height of the current pane (taking into account the lost space due to the header).
func (c *Layer) height() uint {
_, height := c.view.Size()
func (v *Layer) height() uint {
_, height := v.view.Size()
return uint(height - 1)
}
// IsVisible indicates if the layer view pane is currently initialized.
func (c *Layer) IsVisible() bool {
return c != nil
func (v *Layer) IsVisible() bool {
return v != nil
}
// PageDown moves to next page putting the cursor on top
func (c *Layer) PageDown() error {
step := int(c.height()) + 1
targetLayerIndex := c.LayerIndex + step
func (v *Layer) PageDown() error {
step := int(v.height()) + 1
targetLayerIndex := v.LayerIndex + step
if targetLayerIndex > len(c.Layers) {
step -= targetLayerIndex - (len(c.Layers) - 1)
if targetLayerIndex > len(v.Layers) {
step -= targetLayerIndex - (len(v.Layers) - 1)
}
if step > 0 {
err := CursorStep(c.gui, c.view, step)
err := CursorStep(v.gui, v.view, step)
if err == nil {
return c.SetCursor(c.LayerIndex + step)
return v.SetCursor(v.LayerIndex + step)
}
}
return nil
}
// PageUp moves to previous page putting the cursor on top
func (c *Layer) PageUp() error {
step := int(c.height()) + 1
targetLayerIndex := c.LayerIndex - step
func (v *Layer) PageUp() error {
step := int(v.height()) + 1
targetLayerIndex := v.LayerIndex - step
if targetLayerIndex < 0 {
step += targetLayerIndex
}
if step > 0 {
err := CursorStep(c.gui, c.view, -step)
err := CursorStep(v.gui, v.view, -step)
if err == nil {
return c.SetCursor(c.LayerIndex - step)
return v.SetCursor(v.LayerIndex - step)
}
}
return nil
}
// CursorDown moves the cursor down in the layer pane (selecting a higher layer).
func (c *Layer) CursorDown() error {
if c.LayerIndex < len(c.Layers) {
err := CursorDown(c.gui, c.view)
func (v *Layer) CursorDown() error {
if v.LayerIndex < len(v.Layers) {
err := CursorDown(v.gui, v.view)
if err == nil {
return c.SetCursor(c.LayerIndex + 1)
return v.SetCursor(v.LayerIndex + 1)
}
}
return nil
}
// CursorUp moves the cursor up in the layer pane (selecting a lower layer).
func (c *Layer) CursorUp() error {
if c.LayerIndex > 0 {
err := CursorUp(c.gui, c.view)
func (v *Layer) CursorUp() error {
if v.LayerIndex > 0 {
err := CursorUp(v.gui, v.view)
if err == nil {
return c.SetCursor(c.LayerIndex - 1)
return v.SetCursor(v.LayerIndex - 1)
}
}
return nil
}
// SetCursor resets the cursor and orients the file tree view based on the given layer index.
func (c *Layer) SetCursor(layer int) error {
c.LayerIndex = layer
err := c.notifyLayerChangeListeners()
func (v *Layer) SetCursor(layer int) error {
v.LayerIndex = layer
err := v.notifyLayerChangeListeners()
if err != nil {
return err
}
return c.Render()
return v.Render()
}
// CurrentLayer returns the Layer object currently selected.
func (c *Layer) CurrentLayer() *image.Layer {
return c.Layers[c.LayerIndex]
func (v *Layer) CurrentLayer() *image.Layer {
return v.Layers[v.LayerIndex]
}
// setCompareMode switches the layer comparison between a single-layer comparison to an aggregated comparison.
func (c *Layer) setCompareMode(compareMode CompareType) error {
c.CompareMode = compareMode
return c.notifyLayerChangeListeners()
func (v *Layer) setCompareMode(compareMode CompareType) error {
v.CompareMode = compareMode
return v.notifyLayerChangeListeners()
}
// getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode)
func (c *Layer) getCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) {
bottomTreeStart = c.CompareStartIndex
topTreeStop = c.LayerIndex
func (v *Layer) getCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) {
bottomTreeStart = v.CompareStartIndex
topTreeStop = v.LayerIndex
if c.LayerIndex == c.CompareStartIndex {
bottomTreeStop = c.LayerIndex
topTreeStart = c.LayerIndex
} else if c.CompareMode == CompareLayer {
bottomTreeStop = c.LayerIndex - 1
topTreeStart = c.LayerIndex
if v.LayerIndex == v.CompareStartIndex {
bottomTreeStop = v.LayerIndex
topTreeStart = v.LayerIndex
} else if v.CompareMode == CompareLayer {
bottomTreeStop = v.LayerIndex - 1
topTreeStart = v.LayerIndex
} else {
bottomTreeStop = c.CompareStartIndex
topTreeStart = c.CompareStartIndex + 1
bottomTreeStop = v.CompareStartIndex
topTreeStart = v.CompareStartIndex + 1
}
return bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop
}
// renderCompareBar returns the formatted string for the given layer.
func (c *Layer) renderCompareBar(layerIdx int) string {
bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := c.getCompareIndexes()
func (v *Layer) renderCompareBar(layerIdx int) string {
bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.getCompareIndexes()
result := " "
if layerIdx >= bottomTreeStart && layerIdx <= bottomTreeStop {
@ -275,43 +276,44 @@ func (c *Layer) renderCompareBar(layerIdx int) string {
}
// Update refreshes the state objects for future rendering (currently does nothing).
func (c *Layer) Update() error {
func (v *Layer) Update() error {
return nil
}
// Render flushes the state objects to the screen. The layers pane reports:
// 1. the layers of the image + metadata
// 2. the current selected image
func (c *Layer) Render() error {
func (v *Layer) Render() error {
logrus.Debugf("view.Render() %s", v.Name())
// indicate when selected
title := "Layers"
if c.gui.CurrentView() == c.view {
if v.gui.CurrentView() == v.view {
title = "● " + title
}
c.gui.Update(func(g *gocui.Gui) error {
v.gui.Update(func(g *gocui.Gui) error {
// update header
c.header.Clear()
v.header.Clear()
width, _ := g.Size()
headerStr := fmt.Sprintf("[%s]%s\n", title, strings.Repeat("─", width*2))
headerStr += fmt.Sprintf("Cmp"+image.LayerFormat, "Size", "Command")
_, err := fmt.Fprintln(c.header, format.Header(vtclean.Clean(headerStr, false)))
_, err := fmt.Fprintln(v.header, format.Header(vtclean.Clean(headerStr, false)))
if err != nil {
return err
}
// update contents
c.view.Clear()
for idx, layer := range c.Layers {
v.view.Clear()
for idx, layer := range v.Layers {
layerStr := layer.String()
compareBar := c.renderCompareBar(idx)
compareBar := v.renderCompareBar(idx)
if idx == c.LayerIndex {
_, err = fmt.Fprintln(c.view, compareBar+" "+format.Selected(layerStr))
if idx == v.LayerIndex {
_, err = fmt.Fprintln(v.view, compareBar+" "+format.Selected(layerStr))
} else {
_, err = fmt.Fprintln(c.view, compareBar+" "+layerStr)
_, err = fmt.Fprintln(v.view, compareBar+" "+layerStr)
}
if err != nil {
@ -326,9 +328,9 @@ func (c *Layer) Render() error {
}
// KeyHelp indicates all the possible actions a user can take while the current pane is selected.
func (c *Layer) KeyHelp() string {
func (v *Layer) KeyHelp() string {
var help string
for _, binding := range c.helpKeys {
for _, binding := range v.helpKeys {
help += binding.RenderKeyHelp()
}
return help

View file

@ -5,5 +5,8 @@ type Renderer interface {
Update() error
Render() error
IsVisible() bool
}
type Helper interface {
KeyHelp() string
}

View file

@ -5,6 +5,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/wagoodman/dive/runtime/ui/format"
"github.com/wagoodman/dive/runtime/ui/key"
"github.com/wagoodman/dive/utils"
"strings"
"github.com/jroimartin/gocui"
@ -17,76 +18,81 @@ type Status struct {
gui *gocui.Gui
view *gocui.View
selectedView Renderer
selectedView Helper
requestedHeight int
helpKeys []*key.Binding
}
// NewStatusView creates a new view object attached the the global [gocui] screen object.
func NewStatusView(name string, gui *gocui.Gui) (controller *Status) {
// newStatusView creates a new view object attached the the global [gocui] screen object.
func newStatusView(name string, gui *gocui.Gui) (controller *Status) {
controller = new(Status)
// populate main fields
controller.name = name
controller.gui = gui
controller.helpKeys = make([]*key.Binding, 0)
controller.requestedHeight = 1
return controller
}
func (c *Status) SetCurrentView(r Renderer) {
c.selectedView = r
func (v *Status) SetCurrentView(r Helper) {
v.selectedView = r
}
func (c *Status) Name() string {
return c.name
func (v *Status) Name() string {
return v.name
}
func (c *Status) AddHelpKeys(keys ...*key.Binding) {
c.helpKeys = append(c.helpKeys, keys...)
func (v *Status) AddHelpKeys(keys ...*key.Binding) {
v.helpKeys = append(v.helpKeys, keys...)
}
// Setup initializes the UI concerns within the context of a global [gocui] view object.
func (c *Status) Setup(v *gocui.View, header *gocui.View) error {
func (v *Status) Setup(view *gocui.View) error {
logrus.Debugf("view.Setup() %s", v.Name())
// set controller options
c.view = v
c.view.Frame = false
v.view = view
v.view.Frame = false
return c.Render()
return v.Render()
}
// IsVisible indicates if the status view pane is currently initialized.
func (c *Status) IsVisible() bool {
return c != nil
func (v *Status) IsVisible() bool {
return v != nil
}
// CursorDown moves the cursor down in the details pane (currently indicates nothing).
func (c *Status) CursorDown() error {
func (v *Status) CursorDown() error {
return nil
}
// CursorUp moves the cursor up in the details pane (currently indicates nothing).
func (c *Status) CursorUp() error {
func (v *Status) CursorUp() error {
return nil
}
// Update refreshes the state objects for future rendering (currently does nothing).
func (c *Status) Update() error {
func (v *Status) Update() error {
return nil
}
// Render flushes the state objects to the screen.
func (c *Status) Render() error {
c.gui.Update(func(g *gocui.Gui) error {
c.view.Clear()
func (v *Status) Render() error {
logrus.Debugf("view.Render() %s", v.Name())
v.gui.Update(func(g *gocui.Gui) error {
v.view.Clear()
var selectedHelp string
if c.selectedView != nil {
selectedHelp = c.selectedView.KeyHelp()
if v.selectedView != nil {
selectedHelp = v.selectedView.KeyHelp()
}
_, err := fmt.Fprintln(c.view, c.KeyHelp()+selectedHelp+format.StatusNormal("▏"+strings.Repeat(" ", 1000)))
_, err := fmt.Fprintln(v.view, v.KeyHelp()+selectedHelp+format.StatusNormal("▏"+strings.Repeat(" ", 1000)))
if err != nil {
logrus.Debug("unable to write to buffer: ", err)
}
@ -97,10 +103,28 @@ func (c *Status) Render() error {
}
// KeyHelp indicates all the possible global actions a user can take when any pane is selected.
func (c *Status) KeyHelp() string {
func (v *Status) KeyHelp() string {
var help string
for _, binding := range c.helpKeys {
for _, binding := range v.helpKeys {
help += binding.RenderKeyHelp()
}
return help
}
func (v *Status) Layout(g *gocui.Gui, minX, minY, maxX, maxY int, hasResized bool) error {
logrus.Debugf("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name())
view, viewErr := g.SetView(v.Name(), minX, minY, maxX, maxY)
if utils.IsNewView(viewErr) {
err := v.Setup(view)
if err != nil {
logrus.Error("unable to setup status controller", err)
return err
}
}
return nil
}
func (v *Status) RequestedSize(available int) *int {
return &v.requestedHeight
}

56
runtime/ui/view/views.go Normal file
View file

@ -0,0 +1,56 @@
package view
import (
"github.com/jroimartin/gocui"
"github.com/wagoodman/dive/dive/filetree"
"github.com/wagoodman/dive/dive/image"
)
type Views struct {
Tree *FileTree
Layer *Layer
Status *Status
Filter *Filter
Details *Details
all []*Renderer
}
func NewViews(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Comparer) (*Views, error) {
Layer, err := newLayerView("layers", g, analysis.Layers)
if err != nil {
return nil, err
}
treeStack := analysis.RefTrees[0]
Tree, err := newFileTreeView("filetree", g, treeStack, analysis.RefTrees, cache)
if err != nil {
return nil, err
}
Status := newStatusView("status", g)
// set the layer view as the first selected view
Status.SetCurrentView(Layer)
Filter := newFilterView("filter", g)
Details := newDetailsView("details", g, analysis.Efficiency, analysis.Inefficiencies, analysis.SizeBytes)
return &Views{
Tree: Tree,
Layer: Layer,
Status: Status,
Filter: Filter,
Details: Details,
}, nil
}
func (views *Views) All() []Renderer {
return []Renderer{
views.Tree,
views.Layer,
views.Status,
views.Filter,
views.Details,
}
}

20
utils/view.go Normal file
View file

@ -0,0 +1,20 @@
package utils
import (
"github.com/jroimartin/gocui"
"github.com/sirupsen/logrus"
)
// isNewView determines if a view has already been created based on the set of errors given (a bit hokie)
func IsNewView(errs ...error) bool {
for _, err := range errs {
if err == nil {
return false
}
if err != gocui.ErrUnknownView {
logrus.Errorf("IsNewView() unexpected error: %+v", err)
return true
}
}
return true
}