commit
bbc02ef34d
2
Makefile
2
Makefile
@ -40,7 +40,7 @@ test-coverage: build
|
||||
./.scripts/test-coverage.sh
|
||||
|
||||
validate:
|
||||
grep -R 'const allowTestDataCapture = false' runtime/ui/
|
||||
grep -R 'const allowTestDataCapture = false' runtime/ui/viewmodel
|
||||
go vet ./...
|
||||
@! gofmt -s -l . 2>&1 | grep -vE '^\.git/' | grep -vE '^\.cache/'
|
||||
golangci-lint run
|
||||
|
@ -9,7 +9,7 @@ import (
|
||||
|
||||
type resolver struct{}
|
||||
|
||||
func NewResolver() *resolver {
|
||||
func NewResolverFromEngine() *resolver {
|
||||
return &resolver{}
|
||||
}
|
||||
|
||||
|
@ -103,7 +103,7 @@ func run(enableUi bool, options Options, imageResolver image.Resolver, events ev
|
||||
|
||||
err = ui.Run(analysis, cache)
|
||||
if err != nil {
|
||||
events.exitWithErrorMessage("runtime error", err)
|
||||
events.exitWithError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -131,7 +131,6 @@ func Run(options Options) {
|
||||
}
|
||||
|
||||
if event.stderr != "" {
|
||||
logrus.Error(event.stderr)
|
||||
_, err := fmt.Fprintln(os.Stderr, event.stderr)
|
||||
if err != nil {
|
||||
fmt.Println("error: could not write to buffer:", err)
|
||||
@ -140,6 +139,10 @@ func Run(options Options) {
|
||||
|
||||
if event.err != nil {
|
||||
logrus.Error(event.err)
|
||||
_, err := fmt.Fprintln(os.Stderr, event.err.Error())
|
||||
if err != nil {
|
||||
fmt.Println("error: could not write to buffer:", err)
|
||||
}
|
||||
}
|
||||
|
||||
if event.errorOnExit {
|
||||
|
140
runtime/ui/app.go
Normal file
140
runtime/ui/app.go
Normal file
@ -0,0 +1,140 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
"github.com/wagoodman/dive/runtime/ui/key"
|
||||
"sync"
|
||||
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wagoodman/dive/dive/filetree"
|
||||
)
|
||||
|
||||
const debug = false
|
||||
|
||||
// type global
|
||||
type app struct {
|
||||
gui *gocui.Gui
|
||||
controllers *Controller
|
||||
layout *layoutManager
|
||||
}
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
appSingleton *app
|
||||
)
|
||||
|
||||
func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeCache) (*app, error) {
|
||||
var err error
|
||||
once.Do(func() {
|
||||
var theControls *Controller
|
||||
var globalHelpKeys []*key.Binding
|
||||
|
||||
theControls, err = NewCollection(gui, analysis, cache)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
lm := newLayoutManager(theControls)
|
||||
|
||||
gui.Cursor = false
|
||||
//g.Mouse = true
|
||||
gui.SetManagerFunc(lm.layout)
|
||||
|
||||
// var profileObj = profile.Start(profile.CPUProfile, profile.ProfilePath("."), profile.NoShutdownHook)
|
||||
//
|
||||
// onExit = func() {
|
||||
// profileObj.Stop()
|
||||
// }
|
||||
|
||||
appSingleton = &app{
|
||||
gui: gui,
|
||||
controllers: theControls,
|
||||
layout: lm,
|
||||
}
|
||||
|
||||
var infos = []key.BindingInfo{
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.quit"},
|
||||
OnAction: appSingleton.quit,
|
||||
Display: "Quit",
|
||||
},
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.toggle-view"},
|
||||
OnAction: theControls.ToggleView,
|
||||
Display: "Switch view",
|
||||
},
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.filter-files"},
|
||||
OnAction: theControls.ToggleFilterView,
|
||||
IsSelected: theControls.Filter.IsVisible,
|
||||
Display: "Filter",
|
||||
},
|
||||
}
|
||||
|
||||
globalHelpKeys, err = key.GenerateBindings(gui, "", infos)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
theControls.Status.AddHelpKeys(globalHelpKeys...)
|
||||
|
||||
// perform the first update and render now that all resources have been loaded
|
||||
err = theControls.UpdateAndRender()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
return appSingleton, err
|
||||
}
|
||||
|
||||
// var profileObj = profile.Start(profile.MemProfile, profile.ProfilePath("."), profile.NoShutdownHook)
|
||||
// var onExit func()
|
||||
|
||||
// debugPrint writes the given string to the debug pane (if the debug pane is enabled)
|
||||
// func debugPrint(s string) {
|
||||
// if controllers.Tree != nil && controllers.Tree.gui != nil {
|
||||
// v, _ := controllers.Tree.gui.View("debug")
|
||||
// if v != nil {
|
||||
// if len(v.BufferLines()) > 20 {
|
||||
// v.Clear()
|
||||
// }
|
||||
// _, _ = fmt.Fprintln(v, s)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
var lastX, lastY int
|
||||
|
||||
// quit is the gocui callback invoked when the user hits Ctrl+C
|
||||
func (a *app) quit() error {
|
||||
|
||||
// profileObj.Stop()
|
||||
// onExit()
|
||||
|
||||
return gocui.ErrQuit
|
||||
}
|
||||
|
||||
// Run is the UI entrypoint.
|
||||
func Run(analysis *image.AnalysisResult, cache filetree.TreeCache) error {
|
||||
var err error
|
||||
|
||||
g, err := gocui.NewGui(gocui.OutputNormal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
_, err = newApp(g, analysis, cache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := g.MainLoop(); err != nil && err != gocui.ErrQuit {
|
||||
logrus.Error("main loop error: ", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
212
runtime/ui/controller.go
Normal file
212
runtime/ui/controller.go
Normal file
@ -0,0 +1,212 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wagoodman/dive/dive/filetree"
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
"github.com/wagoodman/dive/runtime/ui/view"
|
||||
"github.com/wagoodman/dive/runtime/ui/viewmodel"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func NewCollection(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeCache) (*Controller, error) {
|
||||
var err error
|
||||
|
||||
controller := &Controller{
|
||||
gui: g,
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
|
||||
// update the status pane when a filetree option is changed by the user
|
||||
controller.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
|
||||
|
||||
// propagate initial conditions to necessary views
|
||||
err = controller.onLayerChange(viewmodel.LayerSelection{
|
||||
Layer: controller.Layer.CurrentLayer(),
|
||||
BottomTreeStart: 0,
|
||||
BottomTreeStop: 0,
|
||||
TopTreeStart: 0,
|
||||
TopTreeStop: 0,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return controller, nil
|
||||
}
|
||||
|
||||
func (c *Controller) onFileTreeViewOptionChange() error {
|
||||
err := c.Status.Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status.Render()
|
||||
}
|
||||
|
||||
func (c *Controller) onFilterEdit(filter string) error {
|
||||
var filterRegex *regexp.Regexp
|
||||
var err error
|
||||
|
||||
if len(filter) > 0 {
|
||||
filterRegex, err = regexp.Compile(filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
c.Tree.SetFilterRegex(filterRegex)
|
||||
|
||||
err = c.Tree.Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Tree.Render()
|
||||
}
|
||||
|
||||
func (c *Controller) onLayerChange(selection viewmodel.LayerSelection) error {
|
||||
// update the details
|
||||
c.Details.SetCurrentLayer(selection.Layer)
|
||||
|
||||
// update the filetree
|
||||
err := c.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")
|
||||
} else {
|
||||
c.Tree.SetTitle("Current Layer Contents")
|
||||
}
|
||||
|
||||
// update details and filetree panes
|
||||
return c.UpdateAndRender()
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateAndRender() error {
|
||||
err := c.Update()
|
||||
if err != nil {
|
||||
logrus.Debug("failed update: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = c.Render()
|
||||
if err != nil {
|
||||
logrus.Debug("failed render: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update refreshes the state objects for future rendering.
|
||||
func (c *Controller) Update() error {
|
||||
for _, controller := range c.lookup {
|
||||
err := controller.Update()
|
||||
if err != nil {
|
||||
logrus.Debug("unable to update controller: ")
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render flushes the state objects to the screen.
|
||||
func (c *Controller) Render() error {
|
||||
for _, controller := range c.lookup {
|
||||
if controller.IsVisible() {
|
||||
err := controller.Render()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
} else {
|
||||
_, err = c.gui.SetCurrentView(c.Layer.Name())
|
||||
c.Status.SetCurrentView(c.Layer)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logrus.Error("unable to toggle view: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return c.UpdateAndRender()
|
||||
}
|
||||
|
||||
func (c *Controller) ToggleFilterView() error {
|
||||
// delete all user input from the tree view
|
||||
err := c.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() {
|
||||
// ...remove any filter from the tree
|
||||
c.Tree.SetFilterRegex(nil)
|
||||
|
||||
// ...adjust focus to a valid (visible) view
|
||||
err = c.ToggleView()
|
||||
if err != nil {
|
||||
logrus.Error("unable to toggle filter view (back): ", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return c.UpdateAndRender()
|
||||
}
|
@ -1,161 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wagoodman/dive/dive/filetree"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/lunixbochs/vtclean"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return controller != nil
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
_, err := fmt.Fprintln(controller.header, Formatting.Header(vtclean.Clean(layerHeaderStr, false)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update contents
|
||||
controller.view.Clear()
|
||||
|
||||
var lines = make([]string, 0)
|
||||
if currentLayer.Names != nil && len(currentLayer.Names) > 0 {
|
||||
lines = append(lines, Formatting.Header("Tags: ")+strings.Join(currentLayer.Names, ", "))
|
||||
} else {
|
||||
lines = append(lines, Formatting.Header("Tags: ")+"(none)")
|
||||
}
|
||||
lines = append(lines, Formatting.Header("Id: ")+currentLayer.Id)
|
||||
lines = append(lines, Formatting.Header("Digest: ")+currentLayer.Digest)
|
||||
lines = append(lines, Formatting.Header("Command:"))
|
||||
lines = append(lines, currentLayer.Command)
|
||||
lines = append(lines, "\n"+Formatting.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(controller.view, strings.Join(lines, "\n"))
|
||||
if err != nil {
|
||||
logrus.Debug("unable to write to buffer: ", err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
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"
|
||||
}
|
@ -1,404 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/lunixbochs/vtclean"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/wagoodman/keybinding"
|
||||
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/wagoodman/dive/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
|
||||
keybindingToggleUnmodified []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, err error) {
|
||||
controller = new(FileTreeController)
|
||||
|
||||
// populate main fields
|
||||
controller.Name = name
|
||||
controller.gui = gui
|
||||
controller.vm, err = NewFileTreeViewModel(tree, refTrees, cache)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// support legacy behavior first, then use default behavior
|
||||
controller.keybindingToggleUnmodified, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-unchanged-files"))
|
||||
if err != nil {
|
||||
controller.keybindingToggleUnmodified, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-unmodified-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, err
|
||||
}
|
||||
|
||||
// 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.Modified) }); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, key := range controller.keybindingToggleUnmodified {
|
||||
if err := controller.gui.SetKeybinding(controller.Name, key.Value, key.Modifier, func(*gocui.Gui, *gocui.View) error { return controller.toggleShowDiffType(filetree.Unmodified) }); 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 {
|
||||
return controller != nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if controller.vm.CollapseAll {
|
||||
controller.resetCursor()
|
||||
}
|
||||
_ = 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 (not just this contoller/view)
|
||||
return UpdateAndRender()
|
||||
}
|
||||
|
||||
// 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 (not just this contoller/view)
|
||||
return UpdateAndRender()
|
||||
}
|
||||
|
||||
// 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(resized bool) error {
|
||||
_ = controller.Update()
|
||||
if resized {
|
||||
return controller.Render()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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()
|
||||
err := controller.vm.Render()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprint(controller.view, controller.vm.mainBuf.String())
|
||||
|
||||
return err
|
||||
})
|
||||
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.Modified]) +
|
||||
renderStatusOption(controller.keybindingToggleUnmodified[0].String(), "Unmodified", !controller.vm.HiddenDiffTypes[filetree.Unmodified]) +
|
||||
renderStatusOption(controller.keybindingToggleAttributes[0].String(), "Attributes", controller.vm.ShowAttributes)
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
return controller.Render()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_, err := fmt.Fprintln(controller.header, Formatting.Header(controller.headerStr))
|
||||
if err != nil {
|
||||
logrus.Error("unable to write to buffer: ", err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
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 ")
|
||||
}
|
35
runtime/ui/format/format.go
Normal file
35
runtime/ui/format/format.go
Normal file
@ -0,0 +1,35 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
var (
|
||||
Header func(...interface{}) string
|
||||
Selected func(...interface{}) string
|
||||
StatusSelected func(...interface{}) string
|
||||
StatusNormal func(...interface{}) string
|
||||
StatusControlSelected func(...interface{}) string
|
||||
StatusControlNormal func(...interface{}) string
|
||||
CompareTop func(...interface{}) string
|
||||
CompareBottom func(...interface{}) string
|
||||
)
|
||||
|
||||
func init() {
|
||||
Selected = color.New(color.ReverseVideo, color.Bold).SprintFunc()
|
||||
Header = color.New(color.Bold).SprintFunc()
|
||||
StatusSelected = color.New(color.BgMagenta, color.FgWhite).SprintFunc()
|
||||
StatusNormal = color.New(color.ReverseVideo).SprintFunc()
|
||||
StatusControlSelected = color.New(color.BgMagenta, color.FgWhite, color.Bold).SprintFunc()
|
||||
StatusControlNormal = color.New(color.ReverseVideo, color.Bold).SprintFunc()
|
||||
CompareTop = color.New(color.BgMagenta).SprintFunc()
|
||||
CompareBottom = color.New(color.BgGreen).SprintFunc()
|
||||
}
|
||||
|
||||
func RenderHelpKey(control, title string, selected bool) string {
|
||||
if selected {
|
||||
return StatusSelected("▏") + StatusControlSelected(control) + StatusSelected(" "+title+" ")
|
||||
} else {
|
||||
return StatusNormal("▏") + StatusControlNormal(control) + StatusNormal(" "+title+" ")
|
||||
}
|
||||
}
|
115
runtime/ui/key/binding.go
Normal file
115
runtime/ui/key/binding.go
Normal file
@ -0,0 +1,115 @@
|
||||
package key
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/wagoodman/dive/runtime/ui/format"
|
||||
"github.com/wagoodman/keybinding"
|
||||
)
|
||||
|
||||
type BindingInfo struct {
|
||||
Key gocui.Key
|
||||
Modifier gocui.Modifier
|
||||
ConfigKeys []string
|
||||
OnAction func() error
|
||||
IsSelected func() bool
|
||||
Display string
|
||||
}
|
||||
|
||||
type Binding struct {
|
||||
key []keybinding.Key
|
||||
displayName string
|
||||
selectedFn func() bool
|
||||
actionFn func() error
|
||||
}
|
||||
|
||||
func GenerateBindings(gui *gocui.Gui, influence string, infos []BindingInfo) ([]*Binding, error) {
|
||||
var result = make([]*Binding, 0)
|
||||
for _, info := range infos {
|
||||
var err error
|
||||
var binding *Binding
|
||||
|
||||
if info.ConfigKeys != nil && len(info.ConfigKeys) > 0 {
|
||||
binding, err = NewBindingFromConfig(gui, influence, info.ConfigKeys, info.Display, info.OnAction)
|
||||
} else {
|
||||
binding, err = NewBinding(gui, influence, info.Key, info.Modifier, info.Display, info.OnAction)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info.IsSelected != nil {
|
||||
binding.RegisterSelectionFn(info.IsSelected)
|
||||
}
|
||||
if len(info.Display) > 0 {
|
||||
result = append(result, binding)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func NewBinding(gui *gocui.Gui, influence string, key gocui.Key, mod gocui.Modifier, displayName string, actionFn func() error) (*Binding, error) {
|
||||
return newBinding(gui, influence, []keybinding.Key{{Value: key, Modifier: mod}}, displayName, actionFn)
|
||||
}
|
||||
|
||||
func NewBindingFromConfig(gui *gocui.Gui, influence string, configKeys []string, displayName string, actionFn func() error) (*Binding, error) {
|
||||
var parsedKeys []keybinding.Key
|
||||
for _, configKey := range configKeys {
|
||||
bindStr := viper.GetString(configKey)
|
||||
logrus.Debugf("parsing keybinding '%s' --> '%s'", configKey, bindStr)
|
||||
|
||||
keys, err := keybinding.ParseAll(bindStr)
|
||||
if err == nil && keys != nil && len(keys) > 0 {
|
||||
parsedKeys = keys
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parsedKeys == nil {
|
||||
return nil, fmt.Errorf("could not find configured keybindings for: %+v", configKeys)
|
||||
}
|
||||
|
||||
return newBinding(gui, influence, parsedKeys, displayName, actionFn)
|
||||
}
|
||||
|
||||
func newBinding(gui *gocui.Gui, influence string, keys []keybinding.Key, displayName string, actionFn func() error) (*Binding, error) {
|
||||
binding := &Binding{
|
||||
key: keys,
|
||||
displayName: displayName,
|
||||
actionFn: actionFn,
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if err := gui.SetKeybinding(influence, key.Value, key.Modifier, binding.onAction); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (binding *Binding) RegisterSelectionFn(selectedFn func() bool) {
|
||||
binding.selectedFn = selectedFn
|
||||
}
|
||||
|
||||
func (binding *Binding) onAction(*gocui.Gui, *gocui.View) error {
|
||||
if binding.actionFn == nil {
|
||||
return fmt.Errorf("no action configured for '%+v'", binding)
|
||||
}
|
||||
return binding.actionFn()
|
||||
}
|
||||
|
||||
func (binding *Binding) isSelected() bool {
|
||||
if binding.selectedFn == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return binding.selectedFn()
|
||||
}
|
||||
|
||||
func (binding *Binding) RenderKeyHelp() string {
|
||||
return format.RenderHelpKey(binding.key[0].String(), binding.displayName, binding.isSelected())
|
||||
}
|
@ -1,320 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
"strings"
|
||||
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/lunixbochs/vtclean"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/wagoodman/keybinding"
|
||||
)
|
||||
|
||||
// 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, err error) {
|
||||
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:
|
||||
return nil, fmt.Errorf("unknown layer.show-aggregated-changes value: %v", mode)
|
||||
}
|
||||
|
||||
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, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return controller != nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
if step > 0 {
|
||||
err := CursorStep(controller.gui, controller.view, step)
|
||||
if err == nil {
|
||||
return 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
|
||||
}
|
||||
|
||||
if step > 0 {
|
||||
err := CursorStep(controller.gui, controller.view, -step)
|
||||
if err == nil {
|
||||
return 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 {
|
||||
return 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 {
|
||||
return 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
|
||||
err := Controllers.Tree.setTreeByLayer(controller.getCompareIndexes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = Controllers.Details.Render()
|
||||
|
||||
return controller.Render()
|
||||
}
|
||||
|
||||
// currentLayer returns the Layer object currently selected.
|
||||
func (controller *LayerController) currentLayer() *image.Layer {
|
||||
return controller.Layers[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
|
||||
err := UpdateAndRender()
|
||||
if err != nil {
|
||||
logrus.Errorf("unable to set compare mode: %+v", err)
|
||||
return err
|
||||
}
|
||||
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, "Size", "Command")
|
||||
_, err := fmt.Fprintln(controller.header, Formatting.Header(vtclean.Clean(headerStr, false)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update contents
|
||||
controller.view.Clear()
|
||||
for idx, layer := range controller.Layers {
|
||||
|
||||
layerStr := layer.String()
|
||||
compareBar := controller.renderCompareBar(idx)
|
||||
|
||||
if idx == controller.LayerIndex {
|
||||
_, err = fmt.Fprintln(controller.view, compareBar+" "+Formatting.Selected(layerStr))
|
||||
} else {
|
||||
_, err = fmt.Fprintln(controller.view, compareBar+" "+layerStr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logrus.Debug("unable to write to buffer: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
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)
|
||||
}
|
169
runtime/ui/layout_manager.go
Normal file
169
runtime/ui/layout_manager.go
Normal file
@ -0,0 +1,169 @@
|
||||
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
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"strings"
|
||||
|
||||
"github.com/jroimartin/gocui"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
return controller.Render()
|
||||
}
|
||||
|
||||
// IsVisible indicates if the status view pane is currently initialized.
|
||||
func (controller *StatusController) IsVisible() bool {
|
||||
return controller != nil
|
||||
}
|
||||
|
||||
// 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()
|
||||
_, err := fmt.Fprintln(controller.view, controller.KeyHelp()+Controllers.lookup[controller.gui.CurrentView().Name()].KeyHelp()+Formatting.StatusNormal("▏"+strings.Repeat(" ", 1000)))
|
||||
if err != nil {
|
||||
logrus.Debug("unable to write to buffer: ", err)
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
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())
|
||||
}
|
464
runtime/ui/ui.go
464
runtime/ui/ui.go
@ -1,464 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/wagoodman/dive/dive/filetree"
|
||||
"github.com/wagoodman/keybinding"
|
||||
)
|
||||
|
||||
const debug = false
|
||||
|
||||
// var profileObj = profile.Start(profile.MemProfile, profile.ProfilePath("."), profile.NoShutdownHook)
|
||||
// var onExit func()
|
||||
|
||||
// debugPrint writes the given string to the debug pane (if the debug pane is enabled)
|
||||
// func debugPrint(s string) {
|
||||
// if Controllers.Tree != nil && Controllers.Tree.gui != nil {
|
||||
// v, _ := Controllers.Tree.gui.View("debug")
|
||||
// if v != nil {
|
||||
// if len(v.BufferLines()) > 20 {
|
||||
// v.Clear()
|
||||
// }
|
||||
// _, _ = fmt.Fprintln(v, s)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// Formatting defines standard functions for formatting UI sections.
|
||||
var Formatting struct {
|
||||
Header func(...interface{}) string
|
||||
Selected func(...interface{}) string
|
||||
StatusSelected func(...interface{}) string
|
||||
StatusNormal func(...interface{}) string
|
||||
StatusControlSelected func(...interface{}) string
|
||||
StatusControlNormal func(...interface{}) string
|
||||
CompareTop func(...interface{}) string
|
||||
CompareBottom func(...interface{}) string
|
||||
}
|
||||
|
||||
// Controllers contains all rendered UI panes.
|
||||
var Controllers struct {
|
||||
Tree *FileTreeController
|
||||
Layer *LayerController
|
||||
Status *StatusController
|
||||
Filter *FilterController
|
||||
Details *DetailsController
|
||||
lookup map[string]View
|
||||
}
|
||||
|
||||
var GlobalKeybindings struct {
|
||||
quit []keybinding.Key
|
||||
toggleView []keybinding.Key
|
||||
filterView []keybinding.Key
|
||||
}
|
||||
|
||||
var lastX, lastY int
|
||||
|
||||
// View defines the a renderable terminal screen pane.
|
||||
type View interface {
|
||||
Setup(*gocui.View, *gocui.View) error
|
||||
CursorDown() error
|
||||
CursorUp() error
|
||||
Render() error
|
||||
Update() error
|
||||
KeyHelp() string
|
||||
IsVisible() bool
|
||||
}
|
||||
|
||||
func UpdateAndRender() error {
|
||||
err := Update()
|
||||
if err != nil {
|
||||
logrus.Debug("failed update: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = Render()
|
||||
if err != nil {
|
||||
logrus.Debug("failed render: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// toggleView switches between the file view and the layer view and re-renders the screen.
|
||||
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)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logrus.Error("unable to toggle view: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return UpdateAndRender()
|
||||
}
|
||||
|
||||
// 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
|
||||
Controllers.Filter.view.Clear()
|
||||
|
||||
// toggle hiding
|
||||
Controllers.Filter.hidden = !Controllers.Filter.hidden
|
||||
|
||||
if !Controllers.Filter.hidden {
|
||||
_, err := g.SetCurrentView(Controllers.Filter.Name)
|
||||
if err != nil {
|
||||
logrus.Error("unable to toggle filter view: ", err)
|
||||
return err
|
||||
}
|
||||
return UpdateAndRender()
|
||||
}
|
||||
|
||||
err := toggleView(g, v)
|
||||
if err != nil {
|
||||
logrus.Error("unable to toggle filter view (back): ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = Controllers.Filter.view.SetCursor(0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CursorDown moves the cursor down in the currently selected gocui pane, scrolling the screen as needed.
|
||||
func CursorDown(g *gocui.Gui, v *gocui.View) error {
|
||||
return CursorStep(g, v, 1)
|
||||
}
|
||||
|
||||
// CursorUp moves the cursor up in the currently selected gocui pane, scrolling the screen as needed.
|
||||
func CursorUp(g *gocui.Gui, v *gocui.View) error {
|
||||
return CursorStep(g, v, -1)
|
||||
}
|
||||
|
||||
// Moves the cursor the given step distance, setting the origin to the new cursor line
|
||||
func CursorStep(g *gocui.Gui, v *gocui.View, step int) error {
|
||||
cx, cy := v.Cursor()
|
||||
|
||||
// if there isn't a next line
|
||||
line, err := v.Line(cy + step)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(line) == 0 {
|
||||
return errors.New("unable to move the cursor, empty line")
|
||||
}
|
||||
if err := v.SetCursor(cx, cy+step); err != nil {
|
||||
ox, oy := v.Origin()
|
||||
if err := v.SetOrigin(ox, oy+step); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// quit is the gocui callback invoked when the user hits Ctrl+C
|
||||
func quit(g *gocui.Gui, v *gocui.View) error {
|
||||
|
||||
// profileObj.Stop()
|
||||
// onExit()
|
||||
|
||||
return gocui.ErrQuit
|
||||
}
|
||||
|
||||
// keyBindings registers global key press actions, valid when in any pane.
|
||||
func keyBindings(g *gocui.Gui) error {
|
||||
for _, key := range GlobalKeybindings.quit {
|
||||
if err := g.SetKeybinding("", key.Value, key.Modifier, quit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range GlobalKeybindings.toggleView {
|
||||
if err := g.SetKeybinding("", key.Value, key.Modifier, toggleView); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range GlobalKeybindings.filterView {
|
||||
if err := g.SetKeybinding("", key.Value, key.Modifier, toggleFilterView); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNewView determines if a view has already been created based on the set of errors given (a bit hokie)
|
||||
func isNewView(errs ...error) bool {
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
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 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
|
||||
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
|
||||
}
|
||||
splitCols := int(float64(maxX) * (1.0 - 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(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 Controllers.Filter.hidden {
|
||||
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(Controllers.Layer.Name, -1, -1+headerRows, splitCols, layersHeight)
|
||||
header, headerErr = g.SetView(Controllers.Layer.Name+"header", -1, -1, splitCols, headerRows)
|
||||
if isNewView(viewErr, headerErr) {
|
||||
err = Controllers.Layer.Setup(view, header)
|
||||
if err != nil {
|
||||
logrus.Error("unable to setup layer controller", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = g.SetCurrentView(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 = Controllers.Layer.Render()
|
||||
if err != nil {
|
||||
logrus.Error("unable to render layer view", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Details
|
||||
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) {
|
||||
err = Controllers.Details.Setup(view, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Filetree
|
||||
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) {
|
||||
err = Controllers.Tree.Setup(view, header)
|
||||
if err != nil {
|
||||
logrus.Error("unable to setup tree controller", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = Controllers.Tree.onLayoutChange(resized)
|
||||
if err != nil {
|
||||
logrus.Error("unable to setup layer controller onLayoutChange", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Status Bar
|
||||
view, viewErr = g.SetView(Controllers.Status.Name, -1, maxY-statusBarHeight-statusBarIndex, maxX, maxY-(statusBarIndex-1))
|
||||
if isNewView(viewErr, headerErr) {
|
||||
err = Controllers.Status.Setup(view, nil)
|
||||
if err != nil {
|
||||
logrus.Error("unable to setup status controller", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Filter Bar
|
||||
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) {
|
||||
err = Controllers.Filter.Setup(view, header)
|
||||
if err != nil {
|
||||
logrus.Error("unable to setup filter controller", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update refreshes the state objects for future rendering.
|
||||
func Update() error {
|
||||
for _, controller := range Controllers.lookup {
|
||||
err := controller.Update()
|
||||
if err != nil {
|
||||
logrus.Debug("unable to update controller: ")
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render flushes the state objects to the screen.
|
||||
func Render() error {
|
||||
for _, controller := range Controllers.lookup {
|
||||
if controller.IsVisible() {
|
||||
err := controller.Render()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderStatusOption formats key help bindings-to-title pairs.
|
||||
func renderStatusOption(control, title string, selected bool) string {
|
||||
if selected {
|
||||
return Formatting.StatusSelected("▏") + Formatting.StatusControlSelected(control) + Formatting.StatusSelected(" "+title+" ")
|
||||
} else {
|
||||
return Formatting.StatusNormal("▏") + Formatting.StatusControlNormal(control) + Formatting.StatusNormal(" "+title+" ")
|
||||
}
|
||||
}
|
||||
|
||||
// Run is the UI entrypoint.
|
||||
func Run(analysis *image.AnalysisResult, cache filetree.TreeCache) error {
|
||||
|
||||
Formatting.Selected = color.New(color.ReverseVideo, color.Bold).SprintFunc()
|
||||
Formatting.Header = color.New(color.Bold).SprintFunc()
|
||||
Formatting.StatusSelected = color.New(color.BgMagenta, color.FgWhite).SprintFunc()
|
||||
Formatting.StatusNormal = color.New(color.ReverseVideo).SprintFunc()
|
||||
Formatting.StatusControlSelected = color.New(color.BgMagenta, color.FgWhite, color.Bold).SprintFunc()
|
||||
Formatting.StatusControlNormal = color.New(color.ReverseVideo, color.Bold).SprintFunc()
|
||||
Formatting.CompareTop = color.New(color.BgMagenta).SprintFunc()
|
||||
Formatting.CompareBottom = color.New(color.BgGreen).SprintFunc()
|
||||
|
||||
var err error
|
||||
GlobalKeybindings.quit, err = keybinding.ParseAll(viper.GetString("keybinding.quit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
GlobalKeybindings.toggleView, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-view"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
GlobalKeybindings.filterView, err = keybinding.ParseAll(viper.GetString("keybinding.filter-files"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
g, err := gocui.NewGui(gocui.OutputNormal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
Controllers.lookup = make(map[string]View)
|
||||
|
||||
Controllers.Layer, err = NewLayerController("side", g, analysis.Layers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Controllers.lookup[Controllers.Layer.Name] = Controllers.Layer
|
||||
|
||||
treeStack, err := filetree.StackTreeRange(analysis.RefTrees, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Controllers.Tree, err = NewFileTreeController("main", g, treeStack, analysis.RefTrees, cache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Controllers.lookup[Controllers.Tree.Name] = Controllers.Tree
|
||||
|
||||
Controllers.Status = NewStatusController("status", g)
|
||||
Controllers.lookup[Controllers.Status.Name] = Controllers.Status
|
||||
|
||||
Controllers.Filter = NewFilterController("command", g)
|
||||
Controllers.lookup[Controllers.Filter.Name] = Controllers.Filter
|
||||
|
||||
Controllers.Details = NewDetailsController("details", g, analysis.Efficiency, analysis.Inefficiencies)
|
||||
Controllers.lookup[Controllers.Details.Name] = Controllers.Details
|
||||
|
||||
g.Cursor = false
|
||||
//g.Mouse = true
|
||||
g.SetManagerFunc(layout)
|
||||
|
||||
// var profileObj = profile.Start(profile.CPUProfile, profile.ProfilePath("."), profile.NoShutdownHook)
|
||||
//
|
||||
// onExit = func() {
|
||||
// profileObj.Stop()
|
||||
// }
|
||||
|
||||
// perform the first update and render now that all resources have been loaded
|
||||
err = UpdateAndRender()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := keyBindings(g); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := g.MainLoop(); err != nil && err != gocui.ErrQuit {
|
||||
logrus.Error("main loop error: ", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
37
runtime/ui/view/cursor.go
Normal file
37
runtime/ui/view/cursor.go
Normal file
@ -0,0 +1,37 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/jroimartin/gocui"
|
||||
)
|
||||
|
||||
// CursorDown moves the cursor down in the currently selected gocui pane, scrolling the screen as needed.
|
||||
func CursorDown(g *gocui.Gui, v *gocui.View) error {
|
||||
return CursorStep(g, v, 1)
|
||||
}
|
||||
|
||||
// CursorUp moves the cursor up in the currently selected gocui pane, scrolling the screen as needed.
|
||||
func CursorUp(g *gocui.Gui, v *gocui.View) error {
|
||||
return CursorStep(g, v, -1)
|
||||
}
|
||||
|
||||
// Moves the cursor the given step distance, setting the origin to the new cursor line
|
||||
func CursorStep(g *gocui.Gui, v *gocui.View, step int) error {
|
||||
cx, cy := v.Cursor()
|
||||
|
||||
// if there isn't a next line
|
||||
line, err := v.Line(cy + step)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(line) == 0 {
|
||||
return errors.New("unable to move the cursor, empty line")
|
||||
}
|
||||
if err := v.SetCursor(cx, cy+step); err != nil {
|
||||
ox, oy := v.Origin()
|
||||
if err := v.SetOrigin(ox, oy+step); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
188
runtime/ui/view/details.go
Normal file
188
runtime/ui/view/details.go
Normal file
@ -0,0 +1,188 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wagoodman/dive/dive/filetree"
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
"github.com/wagoodman/dive/runtime/ui/format"
|
||||
"github.com/wagoodman/dive/runtime/ui/key"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/lunixbochs/vtclean"
|
||||
)
|
||||
|
||||
// Details 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 Details struct {
|
||||
name string
|
||||
gui *gocui.Gui
|
||||
view *gocui.View
|
||||
header *gocui.View
|
||||
efficiency float64
|
||||
inefficiencies filetree.EfficiencySlice
|
||||
imageSize uint64
|
||||
|
||||
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) {
|
||||
controller = new(Details)
|
||||
|
||||
// populate main fields
|
||||
controller.name = name
|
||||
controller.gui = gui
|
||||
controller.efficiency = efficiency
|
||||
controller.inefficiencies = inefficiencies
|
||||
controller.imageSize = imageSize
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func (c *Details) Name() string {
|
||||
return c.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 {
|
||||
|
||||
// set controller options
|
||||
c.view = v
|
||||
c.view.Editable = false
|
||||
c.view.Wrap = true
|
||||
c.view.Highlight = false
|
||||
c.view.Frame = false
|
||||
|
||||
c.header = header
|
||||
c.header.Editable = false
|
||||
c.header.Wrap = false
|
||||
c.header.Frame = false
|
||||
|
||||
var infos = []key.BindingInfo{
|
||||
{
|
||||
Key: gocui.KeyArrowDown,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorDown,
|
||||
},
|
||||
{
|
||||
Key: gocui.KeyArrowUp,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorUp,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := key.GenerateBindings(c.gui, c.name, infos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
// IsVisible indicates if the details view pane is currently initialized.
|
||||
func (c *Details) IsVisible() bool {
|
||||
return c != nil
|
||||
}
|
||||
|
||||
// CursorDown moves the cursor down in the details pane (currently indicates nothing).
|
||||
func (c *Details) CursorDown() error {
|
||||
return CursorDown(c.gui, c.view)
|
||||
}
|
||||
|
||||
// CursorUp moves the cursor up in the details pane (currently indicates nothing).
|
||||
func (c *Details) CursorUp() error {
|
||||
return CursorUp(c.gui, c.view)
|
||||
}
|
||||
|
||||
// Update refreshes the state objects for future rendering.
|
||||
func (c *Details) Update() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Details) SetCurrentLayer(layer *image.Layer) {
|
||||
c.currentLayer = layer
|
||||
}
|
||||
|
||||
// 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 (c *Details) Render() error {
|
||||
if c.currentLayer == nil {
|
||||
return fmt.Errorf("no layer selected")
|
||||
}
|
||||
|
||||
var wastedSpace int64
|
||||
|
||||
template := "%5s %12s %-s\n"
|
||||
inefficiencyReport := fmt.Sprintf(format.Header(template), "Count", "Total Space", "Path")
|
||||
|
||||
height := 100
|
||||
if c.view != nil {
|
||||
_, height = c.view.Size()
|
||||
}
|
||||
|
||||
for idx := 0; idx < len(c.inefficiencies); idx++ {
|
||||
data := c.inefficiencies[len(c.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", format.Header("Total Image size:"), humanize.Bytes(c.imageSize))
|
||||
effStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*c.efficiency))
|
||||
wastedSpaceStr := fmt.Sprintf("%s %s", format.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace)))
|
||||
|
||||
c.gui.Update(func(g *gocui.Gui) error {
|
||||
// update header
|
||||
c.header.Clear()
|
||||
width, _ := c.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)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update contents
|
||||
c.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, ", "))
|
||||
} 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("Command:"))
|
||||
lines = append(lines, c.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"))
|
||||
if err != nil {
|
||||
logrus.Debug("unable to write to buffer: ", err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// KeyHelp indicates all the possible actions a user can take while the current pane is selected (currently does nothing).
|
||||
func (c *Details) KeyHelp() string {
|
||||
return "TBD"
|
||||
}
|
402
runtime/ui/view/filetree.go
Normal file
402
runtime/ui/view/filetree.go
Normal file
@ -0,0 +1,402 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wagoodman/dive/runtime/ui/format"
|
||||
"github.com/wagoodman/dive/runtime/ui/key"
|
||||
"github.com/wagoodman/dive/runtime/ui/viewmodel"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/lunixbochs/vtclean"
|
||||
"github.com/wagoodman/dive/dive/filetree"
|
||||
)
|
||||
|
||||
const (
|
||||
CompareLayer CompareType = iota
|
||||
CompareAll
|
||||
)
|
||||
|
||||
type CompareType int
|
||||
|
||||
type ViewOptionChangeListener func() error
|
||||
|
||||
// FileTree holds the UI objects and data models for populating the right pane. Specifically the pane that
|
||||
// shows selected layer or aggregate file ASCII tree.
|
||||
type FileTree struct {
|
||||
name string
|
||||
gui *gocui.Gui
|
||||
view *gocui.View
|
||||
header *gocui.View
|
||||
vm *viewmodel.FileTree
|
||||
title string
|
||||
|
||||
filterRegex *regexp.Regexp
|
||||
|
||||
listeners []ViewOptionChangeListener
|
||||
|
||||
helpKeys []*key.Binding
|
||||
}
|
||||
|
||||
// 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) (controller *FileTree, err error) {
|
||||
controller = new(FileTree)
|
||||
controller.listeners = make([]ViewOptionChangeListener, 0)
|
||||
|
||||
// populate main fields
|
||||
controller.name = name
|
||||
controller.gui = gui
|
||||
controller.vm, err = viewmodel.NewFileTreeViewModel(tree, refTrees, cache)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return controller, err
|
||||
}
|
||||
|
||||
func (c *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListener) {
|
||||
c.listeners = append(c.listeners, listener...)
|
||||
}
|
||||
|
||||
func (c *FileTree) SetTitle(title string) {
|
||||
c.title = title
|
||||
}
|
||||
|
||||
func (c *FileTree) SetFilterRegex(filterRegex *regexp.Regexp) {
|
||||
c.filterRegex = filterRegex
|
||||
}
|
||||
|
||||
func (c *FileTree) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
func (c *FileTree) AreAttributesVisible() bool {
|
||||
return c.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 {
|
||||
|
||||
// set controller options
|
||||
c.view = v
|
||||
c.view.Editable = false
|
||||
c.view.Wrap = false
|
||||
c.view.Frame = false
|
||||
|
||||
c.header = header
|
||||
c.header.Editable = false
|
||||
c.header.Wrap = false
|
||||
c.header.Frame = false
|
||||
|
||||
var infos = []key.BindingInfo{
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.toggle-collapse-dir"},
|
||||
OnAction: c.toggleCollapse,
|
||||
Display: "Collapse dir",
|
||||
},
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.toggle-collapse-all-dir"},
|
||||
OnAction: c.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] },
|
||||
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] },
|
||||
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] },
|
||||
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] },
|
||||
Display: "Unmodified",
|
||||
},
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.toggle-filetree-attributes"},
|
||||
OnAction: c.toggleAttributes,
|
||||
IsSelected: func() bool { return c.vm.ShowAttributes },
|
||||
Display: "Attributes",
|
||||
},
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.page-up"},
|
||||
OnAction: c.PageUp,
|
||||
},
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.page-down"},
|
||||
OnAction: c.PageDown,
|
||||
},
|
||||
{
|
||||
Key: gocui.KeyArrowDown,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorDown,
|
||||
},
|
||||
{
|
||||
Key: gocui.KeyArrowUp,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorUp,
|
||||
},
|
||||
{
|
||||
Key: gocui.KeyArrowLeft,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorLeft,
|
||||
},
|
||||
{
|
||||
Key: gocui.KeyArrowRight,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorRight,
|
||||
},
|
||||
}
|
||||
|
||||
helpKeys, err := key.GenerateBindings(c.gui, c.name, infos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.helpKeys = helpKeys
|
||||
|
||||
_, height := c.view.Size()
|
||||
c.vm.Setup(0, height)
|
||||
_ = c.Update()
|
||||
_ = c.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsVisible indicates if the file tree view pane is currently initialized
|
||||
func (c *FileTree) IsVisible() bool {
|
||||
return c != 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()
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = c.Update()
|
||||
return c.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()
|
||||
}
|
||||
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 (c *FileTree) CursorUp() error {
|
||||
if c.vm.CursorUp() {
|
||||
return c.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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = c.Update()
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
// CursorRight descends into directory expanding it if needed
|
||||
func (c *FileTree) CursorRight() error {
|
||||
err := c.vm.CursorRight(c.filterRegex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = c.Update()
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
// PageDown moves to next page putting the cursor on top
|
||||
func (c *FileTree) PageDown() error {
|
||||
err := c.vm.PageDown()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
// PageUp moves to previous page putting the cursor on top
|
||||
func (c *FileTree) PageUp() error {
|
||||
err := c.vm.PageUp()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
// getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode.
|
||||
// func (controller *FileTree) getAbsPositionNode() (node *filetree.FileNode) {
|
||||
// return controller.vm.getAbsPositionNode(filterRegex())
|
||||
// }
|
||||
|
||||
// ToggleCollapse will collapse/expand the selected FileNode.
|
||||
func (c *FileTree) toggleCollapse() error {
|
||||
err := c.vm.ToggleCollapse(c.filterRegex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = c.Update()
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
// ToggleCollapseAll will collapse/expand the all directories.
|
||||
func (c *FileTree) toggleCollapseAll() error {
|
||||
err := c.vm.ToggleCollapseAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.vm.CollapseAll {
|
||||
c.resetCursor()
|
||||
}
|
||||
_ = c.Update()
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
func (c *FileTree) notifyOnViewOptionChangeListeners() error {
|
||||
for _, listener := range c.listeners {
|
||||
err := listener()
|
||||
if err != nil {
|
||||
logrus.Errorf("notifyOnViewOptionChangeListeners error: %+v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToggleAttributes will show/hide file attributes
|
||||
func (c *FileTree) toggleAttributes() error {
|
||||
err := c.vm.ToggleAttributes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = c.Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = c.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()
|
||||
}
|
||||
|
||||
// ToggleShowDiffType will show/hide the selected DiffType in the filetree pane.
|
||||
func (c *FileTree) toggleShowDiffType(diffType filetree.DiffType) error {
|
||||
c.vm.ToggleShowDiffType(diffType)
|
||||
|
||||
err := c.Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = c.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()
|
||||
}
|
||||
|
||||
// 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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resized {
|
||||
return c.Render()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update refreshes the state objects for future rendering.
|
||||
func (c *FileTree) Update() error {
|
||||
var width, height int
|
||||
|
||||
if c.view != nil {
|
||||
width, height = c.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()
|
||||
}
|
||||
// height should account for the header
|
||||
return c.vm.Update(c.filterRegex, width, height-1)
|
||||
}
|
||||
|
||||
// Render flushes the state objects (file tree) to the pane.
|
||||
func (c *FileTree) Render() error {
|
||||
title := c.title
|
||||
// indicate when selected
|
||||
if c.gui.CurrentView() == c.view {
|
||||
title = "● " + c.title
|
||||
}
|
||||
|
||||
c.gui.Update(func(g *gocui.Gui) error {
|
||||
// update the header
|
||||
c.header.Clear()
|
||||
width, _ := g.Size()
|
||||
headerStr := fmt.Sprintf("[%s]%s\n", title, strings.Repeat("─", width*2))
|
||||
if c.vm.ShowAttributes {
|
||||
headerStr += fmt.Sprintf(filetree.AttributeFormat+" %s", "P", "ermission", "UID:GID", "Size", "Filetree")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(c.header, format.Header(vtclean.Clean(headerStr, false)))
|
||||
|
||||
// update the contents
|
||||
c.view.Clear()
|
||||
err := c.vm.Render()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprint(c.view, c.vm.Buffer.String())
|
||||
|
||||
return err
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// KeyHelp indicates all the possible actions a user can take while the current pane is selected.
|
||||
func (c *FileTree) KeyHelp() string {
|
||||
var help string
|
||||
for _, binding := range c.helpKeys {
|
||||
help += binding.RenderKeyHelp()
|
||||
}
|
||||
return help
|
||||
}
|
169
runtime/ui/view/filter.go
Normal file
169
runtime/ui/view/filter.go
Normal file
@ -0,0 +1,169 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wagoodman/dive/runtime/ui/format"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
filterEditListeners []FilterEditListener
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// populate main fields
|
||||
controller.name = name
|
||||
controller.gui = gui
|
||||
controller.headerStr = "Path Filter: "
|
||||
controller.hidden = true
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func (c *Filter) AddFilterEditListener(listener ...FilterEditListener) {
|
||||
c.filterEditListeners = append(c.filterEditListeners, listener...)
|
||||
}
|
||||
|
||||
func (c *Filter) Name() string {
|
||||
return c.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 {
|
||||
|
||||
// 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
|
||||
|
||||
c.header = header
|
||||
c.header.BgColor = gocui.AttrReverse
|
||||
c.header.Editable = false
|
||||
c.header.Wrap = false
|
||||
c.header.Frame = false
|
||||
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
// ToggleFilterView shows/hides the file tree filter pane.
|
||||
func (c *Filter) ToggleVisible() error {
|
||||
// delete all user input from the tree view
|
||||
c.view.Clear()
|
||||
|
||||
// toggle hiding
|
||||
c.hidden = !c.hidden
|
||||
|
||||
if !c.hidden {
|
||||
_, err := c.gui.SetCurrentView(c.name)
|
||||
if err != nil {
|
||||
logrus.Error("unable to toggle filter view: ", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// IsVisible indicates if the filter view pane is currently initialized
|
||||
func (c *Filter) IsVisible() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return !c.hidden
|
||||
}
|
||||
|
||||
// CursorDown moves the cursor down in the filter pane (currently indicates nothing).
|
||||
func (c *Filter) CursorDown() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CursorUp moves the cursor up in the filter pane (currently indicates nothing).
|
||||
func (c *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() {
|
||||
return
|
||||
}
|
||||
|
||||
cx, _ := v.Cursor()
|
||||
ox, _ := v.Origin()
|
||||
limit := ox+cx+1 > c.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)
|
||||
}
|
||||
|
||||
// notify listeners
|
||||
c.notifyFilterEditListeners()
|
||||
}
|
||||
|
||||
func (c *Filter) notifyFilterEditListeners() {
|
||||
currentValue := strings.TrimSpace(c.view.Buffer())
|
||||
for _, listener := range c.filterEditListeners {
|
||||
err := listener(currentValue)
|
||||
if err != nil {
|
||||
// note: cannot propagate error from here since this is from the main gogui thread
|
||||
logrus.Errorf("notifyFilterEditListeners: %+v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update refreshes the state objects for future rendering (currently does nothing).
|
||||
func (c *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))
|
||||
if err != nil {
|
||||
logrus.Error("unable to write to buffer: ", err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// KeyHelp indicates all the possible actions a user can take while the current pane is selected.
|
||||
func (c *Filter) KeyHelp() string {
|
||||
return format.StatusControlNormal("▏Type to filter the file tree ")
|
||||
}
|
335
runtime/ui/view/layer.go
Normal file
335
runtime/ui/view/layer.go
Normal file
@ -0,0 +1,335 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
"github.com/wagoodman/dive/runtime/ui/format"
|
||||
"github.com/wagoodman/dive/runtime/ui/key"
|
||||
"github.com/wagoodman/dive/runtime/ui/viewmodel"
|
||||
"strings"
|
||||
|
||||
"github.com/jroimartin/gocui"
|
||||
"github.com/lunixbochs/vtclean"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type LayerChangeListener func(viewmodel.LayerSelection) error
|
||||
|
||||
// Layer 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 Layer struct {
|
||||
name string
|
||||
gui *gocui.Gui
|
||||
view *gocui.View
|
||||
header *gocui.View
|
||||
LayerIndex int
|
||||
Layers []*image.Layer
|
||||
CompareMode CompareType
|
||||
CompareStartIndex int
|
||||
|
||||
listeners []LayerChangeListener
|
||||
|
||||
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) {
|
||||
controller = new(Layer)
|
||||
|
||||
controller.listeners = make([]LayerChangeListener, 0)
|
||||
|
||||
// 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:
|
||||
return nil, fmt.Errorf("unknown layer.show-aggregated-changes value: %v", mode)
|
||||
}
|
||||
|
||||
return controller, err
|
||||
}
|
||||
|
||||
func (c *Layer) AddLayerChangeListener(listener ...LayerChangeListener) {
|
||||
c.listeners = append(c.listeners, listener...)
|
||||
}
|
||||
|
||||
func (c *Layer) notifyLayerChangeListeners() error {
|
||||
bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := c.getCompareIndexes()
|
||||
selection := viewmodel.LayerSelection{
|
||||
Layer: c.CurrentLayer(),
|
||||
BottomTreeStart: bottomTreeStart,
|
||||
BottomTreeStop: bottomTreeStop,
|
||||
TopTreeStart: topTreeStart,
|
||||
TopTreeStop: topTreeStop,
|
||||
}
|
||||
for _, listener := range c.listeners {
|
||||
err := listener(selection)
|
||||
if err != nil {
|
||||
logrus.Errorf("notifyLayerChangeListeners error: %+v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Layer) Name() string {
|
||||
return c.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 {
|
||||
|
||||
// set controller options
|
||||
c.view = v
|
||||
c.view.Editable = false
|
||||
c.view.Wrap = false
|
||||
c.view.Frame = false
|
||||
|
||||
c.header = header
|
||||
c.header.Editable = false
|
||||
c.header.Wrap = false
|
||||
c.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 },
|
||||
Display: "Show layer changes",
|
||||
},
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.compare-all"},
|
||||
OnAction: func() error { return c.setCompareMode(CompareAll) },
|
||||
IsSelected: func() bool { return c.CompareMode == CompareAll },
|
||||
Display: "Show aggregated changes",
|
||||
},
|
||||
{
|
||||
Key: gocui.KeyArrowDown,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorDown,
|
||||
},
|
||||
{
|
||||
Key: gocui.KeyArrowUp,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorUp,
|
||||
},
|
||||
{
|
||||
Key: gocui.KeyArrowLeft,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorUp,
|
||||
},
|
||||
{
|
||||
Key: gocui.KeyArrowRight,
|
||||
Modifier: gocui.ModNone,
|
||||
OnAction: c.CursorDown,
|
||||
},
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.page-up"},
|
||||
OnAction: c.PageUp,
|
||||
},
|
||||
{
|
||||
ConfigKeys: []string{"keybinding.page-down"},
|
||||
OnAction: c.PageDown,
|
||||
},
|
||||
}
|
||||
|
||||
helpKeys, err := key.GenerateBindings(c.gui, c.name, infos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.helpKeys = helpKeys
|
||||
|
||||
return c.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()
|
||||
return uint(height - 1)
|
||||
}
|
||||
|
||||
// IsVisible indicates if the layer view pane is currently initialized.
|
||||
func (c *Layer) IsVisible() bool {
|
||||
return c != 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
|
||||
|
||||
if targetLayerIndex > len(c.Layers) {
|
||||
step -= targetLayerIndex - (len(c.Layers) - 1)
|
||||
}
|
||||
|
||||
if step > 0 {
|
||||
err := CursorStep(c.gui, c.view, step)
|
||||
if err == nil {
|
||||
return c.SetCursor(c.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
|
||||
|
||||
if targetLayerIndex < 0 {
|
||||
step += targetLayerIndex
|
||||
}
|
||||
|
||||
if step > 0 {
|
||||
err := CursorStep(c.gui, c.view, -step)
|
||||
if err == nil {
|
||||
return c.SetCursor(c.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)
|
||||
if err == nil {
|
||||
return c.SetCursor(c.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)
|
||||
if err == nil {
|
||||
return c.SetCursor(c.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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
// CurrentLayer returns the Layer object currently selected.
|
||||
func (c *Layer) CurrentLayer() *image.Layer {
|
||||
return c.Layers[c.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()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
if c.LayerIndex == c.CompareStartIndex {
|
||||
bottomTreeStop = c.LayerIndex
|
||||
topTreeStart = c.LayerIndex
|
||||
} else if c.CompareMode == CompareLayer {
|
||||
bottomTreeStop = c.LayerIndex - 1
|
||||
topTreeStart = c.LayerIndex
|
||||
} else {
|
||||
bottomTreeStop = c.CompareStartIndex
|
||||
topTreeStart = c.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()
|
||||
result := " "
|
||||
|
||||
if layerIdx >= bottomTreeStart && layerIdx <= bottomTreeStop {
|
||||
result = format.CompareBottom(" ")
|
||||
}
|
||||
if layerIdx >= topTreeStart && layerIdx <= topTreeStop {
|
||||
result = format.CompareTop(" ")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Update refreshes the state objects for future rendering (currently does nothing).
|
||||
func (c *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 {
|
||||
|
||||
// indicate when selected
|
||||
title := "Layers"
|
||||
if c.gui.CurrentView() == c.view {
|
||||
title = "● " + title
|
||||
}
|
||||
|
||||
c.gui.Update(func(g *gocui.Gui) error {
|
||||
// update header
|
||||
c.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)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update contents
|
||||
c.view.Clear()
|
||||
for idx, layer := range c.Layers {
|
||||
|
||||
layerStr := layer.String()
|
||||
compareBar := c.renderCompareBar(idx)
|
||||
|
||||
if idx == c.LayerIndex {
|
||||
_, err = fmt.Fprintln(c.view, compareBar+" "+format.Selected(layerStr))
|
||||
} else {
|
||||
_, err = fmt.Fprintln(c.view, compareBar+" "+layerStr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logrus.Debug("unable to write to buffer: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// KeyHelp indicates all the possible actions a user can take while the current pane is selected.
|
||||
func (c *Layer) KeyHelp() string {
|
||||
var help string
|
||||
for _, binding := range c.helpKeys {
|
||||
help += binding.RenderKeyHelp()
|
||||
}
|
||||
return help
|
||||
}
|
9
runtime/ui/view/renderer.go
Normal file
9
runtime/ui/view/renderer.go
Normal file
@ -0,0 +1,9 @@
|
||||
package view
|
||||
|
||||
// Controller defines the a renderable terminal screen pane.
|
||||
type Renderer interface {
|
||||
Update() error
|
||||
Render() error
|
||||
IsVisible() bool
|
||||
KeyHelp() string
|
||||
}
|
106
runtime/ui/view/status.go
Normal file
106
runtime/ui/view/status.go
Normal file
@ -0,0 +1,106 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wagoodman/dive/runtime/ui/format"
|
||||
"github.com/wagoodman/dive/runtime/ui/key"
|
||||
"strings"
|
||||
|
||||
"github.com/jroimartin/gocui"
|
||||
)
|
||||
|
||||
// Status holds the UI objects and data models for populating the bottom-most pane. Specifically the panel
|
||||
// shows the user a set of possible actions to take in the window and currently selected pane.
|
||||
type Status struct {
|
||||
name string
|
||||
gui *gocui.Gui
|
||||
view *gocui.View
|
||||
|
||||
selectedView Renderer
|
||||
|
||||
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) {
|
||||
controller = new(Status)
|
||||
|
||||
// populate main fields
|
||||
controller.name = name
|
||||
controller.gui = gui
|
||||
controller.helpKeys = make([]*key.Binding, 0)
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func (c *Status) SetCurrentView(r Renderer) {
|
||||
c.selectedView = r
|
||||
}
|
||||
|
||||
func (c *Status) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
func (c *Status) AddHelpKeys(keys ...*key.Binding) {
|
||||
c.helpKeys = append(c.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 {
|
||||
|
||||
// set controller options
|
||||
c.view = v
|
||||
c.view.Frame = false
|
||||
|
||||
return c.Render()
|
||||
}
|
||||
|
||||
// IsVisible indicates if the status view pane is currently initialized.
|
||||
func (c *Status) IsVisible() bool {
|
||||
return c != nil
|
||||
}
|
||||
|
||||
// CursorDown moves the cursor down in the details pane (currently indicates nothing).
|
||||
func (c *Status) CursorDown() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CursorUp moves the cursor up in the details pane (currently indicates nothing).
|
||||
func (c *Status) CursorUp() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update refreshes the state objects for future rendering (currently does nothing).
|
||||
func (c *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()
|
||||
|
||||
var selectedHelp string
|
||||
if c.selectedView != nil {
|
||||
selectedHelp = c.selectedView.KeyHelp()
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintln(c.view, c.KeyHelp()+selectedHelp+format.StatusNormal("▏"+strings.Repeat(" ", 1000)))
|
||||
if err != nil {
|
||||
logrus.Debug("unable to write to buffer: ", err)
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// KeyHelp indicates all the possible global actions a user can take when any pane is selected.
|
||||
func (c *Status) KeyHelp() string {
|
||||
var help string
|
||||
for _, binding := range c.helpKeys {
|
||||
help += binding.RenderKeyHelp()
|
||||
}
|
||||
return help
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
package ui
|
||||
package viewmodel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/wagoodman/dive/runtime/ui/format"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@ -14,7 +15,7 @@ import (
|
||||
|
||||
// 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 {
|
||||
type FileTree struct {
|
||||
ModelTree *filetree.FileTree
|
||||
ViewTree *filetree.FileTree
|
||||
RefTrees []*filetree.FileTree
|
||||
@ -30,12 +31,12 @@ type FileTreeViewModel struct {
|
||||
refHeight int
|
||||
refWidth int
|
||||
|
||||
mainBuf bytes.Buffer
|
||||
Buffer 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, err error) {
|
||||
treeViewModel = new(FileTreeViewModel)
|
||||
// NewFileTreeViewModel creates a new view object attached the the global [gocui] screen object.
|
||||
func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (treeViewModel *FileTree, err error) {
|
||||
treeViewModel = new(FileTree)
|
||||
|
||||
// populate main fields
|
||||
treeViewModel.ShowAttributes = viper.GetBool("filetree.show-attributes")
|
||||
@ -65,13 +66,13 @@ func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree
|
||||
}
|
||||
|
||||
// Setup initializes the UI concerns within the context of a global [gocui] view object.
|
||||
func (vm *FileTreeViewModel) Setup(lowerBound, height int) {
|
||||
func (vm *FileTree) Setup(lowerBound, height int) {
|
||||
vm.bufferIndexLowerBound = lowerBound
|
||||
vm.refHeight = height
|
||||
}
|
||||
|
||||
// height returns the current height and considers the header
|
||||
func (vm *FileTreeViewModel) height() int {
|
||||
func (vm *FileTree) height() int {
|
||||
if vm.ShowAttributes {
|
||||
return vm.refHeight - 1
|
||||
}
|
||||
@ -79,24 +80,24 @@ func (vm *FileTreeViewModel) height() int {
|
||||
}
|
||||
|
||||
// bufferIndexUpperBound returns the current upper bounds for the view
|
||||
func (vm *FileTreeViewModel) bufferIndexUpperBound() int {
|
||||
func (vm *FileTree) bufferIndexUpperBound() int {
|
||||
return vm.bufferIndexLowerBound + vm.height()
|
||||
}
|
||||
|
||||
// IsVisible indicates if the file tree view pane is currently initialized
|
||||
func (vm *FileTreeViewModel) IsVisible() bool {
|
||||
func (vm *FileTree) IsVisible() bool {
|
||||
return vm != nil
|
||||
}
|
||||
|
||||
// resetCursor moves the cursor back to the top of the buffer and translates to the top of the buffer.
|
||||
func (vm *FileTreeViewModel) resetCursor() {
|
||||
// ResetCursor moves the cursor back to the top of the buffer and translates to the top of the buffer.
|
||||
func (vm *FileTree) 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 {
|
||||
// SetTreeByLayer populates the view model by stacking the indicated image layer file trees.
|
||||
func (vm *FileTree) 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)
|
||||
}
|
||||
@ -125,7 +126,7 @@ func (vm *FileTreeViewModel) setTreeByLayer(bottomTreeStart, bottomTreeStop, top
|
||||
}
|
||||
|
||||
// doCursorUp performs the internal view's buffer adjustments on cursor up. Note: this is independent of the gocui buffer.
|
||||
func (vm *FileTreeViewModel) CursorUp() bool {
|
||||
func (vm *FileTree) CursorUp() bool {
|
||||
if vm.TreeIndex <= 0 {
|
||||
return false
|
||||
}
|
||||
@ -140,7 +141,7 @@ func (vm *FileTreeViewModel) CursorUp() bool {
|
||||
}
|
||||
|
||||
// doCursorDown performs the internal view's buffer adjustments on cursor down. Note: this is independent of the gocui buffer.
|
||||
func (vm *FileTreeViewModel) CursorDown() bool {
|
||||
func (vm *FileTree) CursorDown() bool {
|
||||
if vm.TreeIndex >= vm.ModelTree.VisibleSize() {
|
||||
return false
|
||||
}
|
||||
@ -156,7 +157,7 @@ func (vm *FileTreeViewModel) CursorDown() bool {
|
||||
}
|
||||
|
||||
// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree
|
||||
func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error {
|
||||
func (vm *FileTree) CursorLeft(filterRegex *regexp.Regexp) error {
|
||||
var visitor func(*filetree.FileNode) error
|
||||
var evaluator func(*filetree.FileNode) bool
|
||||
var dfsCounter, newIndex int
|
||||
@ -207,7 +208,7 @@ func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error {
|
||||
}
|
||||
|
||||
// CursorRight descends into directory expanding it if needed
|
||||
func (vm *FileTreeViewModel) CursorRight(filterRegex *regexp.Regexp) error {
|
||||
func (vm *FileTree) CursorRight(filterRegex *regexp.Regexp) error {
|
||||
node := vm.getAbsPositionNode(filterRegex)
|
||||
if node == nil {
|
||||
return nil
|
||||
@ -239,7 +240,7 @@ func (vm *FileTreeViewModel) CursorRight(filterRegex *regexp.Regexp) error {
|
||||
}
|
||||
|
||||
// PageDown moves to next page putting the cursor on top
|
||||
func (vm *FileTreeViewModel) PageDown() error {
|
||||
func (vm *FileTree) PageDown() error {
|
||||
nextBufferIndexLowerBound := vm.bufferIndexLowerBound + vm.height()
|
||||
nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height()
|
||||
|
||||
@ -265,7 +266,7 @@ func (vm *FileTreeViewModel) PageDown() error {
|
||||
}
|
||||
|
||||
// PageUp moves to previous page putting the cursor on top
|
||||
func (vm *FileTreeViewModel) PageUp() error {
|
||||
func (vm *FileTree) PageUp() error {
|
||||
nextBufferIndexLowerBound := vm.bufferIndexLowerBound - vm.height()
|
||||
nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height()
|
||||
|
||||
@ -290,7 +291,7 @@ func (vm *FileTreeViewModel) PageUp() error {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func (vm *FileTree) getAbsPositionNode(filterRegex *regexp.Regexp) (node *filetree.FileNode) {
|
||||
var visitor func(*filetree.FileNode) error
|
||||
var evaluator func(*filetree.FileNode) bool
|
||||
var dfsCounter int
|
||||
@ -320,8 +321,8 @@ func (vm *FileTreeViewModel) getAbsPositionNode(filterRegex *regexp.Regexp) (nod
|
||||
return node
|
||||
}
|
||||
|
||||
// toggleCollapse will collapse/expand the selected FileNode.
|
||||
func (vm *FileTreeViewModel) toggleCollapse(filterRegex *regexp.Regexp) error {
|
||||
// ToggleCollapse will collapse/expand the selected FileNode.
|
||||
func (vm *FileTree) ToggleCollapse(filterRegex *regexp.Regexp) error {
|
||||
node := vm.getAbsPositionNode(filterRegex)
|
||||
if node != nil && node.Data.FileInfo.IsDir {
|
||||
node.Data.ViewInfo.Collapsed = !node.Data.ViewInfo.Collapsed
|
||||
@ -329,8 +330,8 @@ func (vm *FileTreeViewModel) toggleCollapse(filterRegex *regexp.Regexp) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// toggleCollapseAll will collapse/expand the all directories.
|
||||
func (vm *FileTreeViewModel) toggleCollapseAll() error {
|
||||
// ToggleCollapseAll will collapse/expand the all directories.
|
||||
func (vm *FileTree) ToggleCollapseAll() error {
|
||||
vm.CollapseAll = !vm.CollapseAll
|
||||
|
||||
visitor := func(curNode *filetree.FileNode) error {
|
||||
@ -344,25 +345,25 @@ func (vm *FileTreeViewModel) toggleCollapseAll() error {
|
||||
|
||||
err := vm.ModelTree.VisitDepthChildFirst(visitor, evaluator)
|
||||
if err != nil {
|
||||
logrus.Errorf("unable to propagate tree on toggleCollapseAll: %+v", err)
|
||||
logrus.Errorf("unable to propagate tree on ToggleCollapseAll: %+v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// toggleCollapse will collapse/expand the selected FileNode.
|
||||
func (vm *FileTreeViewModel) toggleAttributes() error {
|
||||
// ToggleCollapse will collapse/expand the selected FileNode.
|
||||
func (vm *FileTree) 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) {
|
||||
// ToggleShowDiffType will show/hide the selected DiffType in the filetree pane.
|
||||
func (vm *FileTree) ToggleShowDiffType(diffType filetree.DiffType) {
|
||||
vm.HiddenDiffTypes[diffType] = !vm.HiddenDiffTypes[diffType]
|
||||
}
|
||||
|
||||
// Update refreshes the state objects for future rendering.
|
||||
func (vm *FileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height int) error {
|
||||
func (vm *FileTree) Update(filterRegex *regexp.Regexp, width, height int) error {
|
||||
vm.refWidth = width
|
||||
vm.refHeight = height
|
||||
|
||||
@ -410,21 +411,21 @@ func (vm *FileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height in
|
||||
}
|
||||
|
||||
// Render flushes the state objects (file tree) to the pane.
|
||||
func (vm *FileTreeViewModel) Render() error {
|
||||
func (vm *FileTree) Render() error {
|
||||
treeString := vm.ViewTree.StringBetween(vm.bufferIndexLowerBound, vm.bufferIndexUpperBound(), vm.ShowAttributes)
|
||||
lines := strings.Split(treeString, "\n")
|
||||
|
||||
// update the contents
|
||||
vm.mainBuf.Reset()
|
||||
vm.Buffer.Reset()
|
||||
for idx, line := range lines {
|
||||
if idx == vm.bufferIndex {
|
||||
_, err := fmt.Fprintln(&vm.mainBuf, Formatting.Selected(vtclean.Clean(line, false)))
|
||||
_, err := fmt.Fprintln(&vm.Buffer, format.Selected(vtclean.Clean(line, false)))
|
||||
if err != nil {
|
||||
logrus.Debug("unable to write to buffer: ", err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_, err := fmt.Fprintln(&vm.mainBuf, line)
|
||||
_, err := fmt.Fprintln(&vm.Buffer, line)
|
||||
if err != nil {
|
||||
logrus.Debug("unable to write to buffer: ", err)
|
||||
return err
|
@ -1,8 +1,9 @@
|
||||
package ui
|
||||
package viewmodel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/wagoodman/dive/dive/image/docker"
|
||||
"github.com/wagoodman/dive/runtime/ui/format"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -72,8 +73,8 @@ func assertTestData(t *testing.T, actualBytes []byte) {
|
||||
helperCheckDiff(t, expectedBytes, actualBytes)
|
||||
}
|
||||
|
||||
func initializeTestViewModel(t *testing.T) *FileTreeViewModel {
|
||||
result := docker.TestAnalysisFromArchive(t, "../../.data/test-docker-image.tar")
|
||||
func initializeTestViewModel(t *testing.T) *FileTree {
|
||||
result := docker.TestAnalysisFromArchive(t, "../../../.data/test-docker-image.tar")
|
||||
|
||||
cache := filetree.NewFileTreeCache(result.RefTrees)
|
||||
err := cache.Build()
|
||||
@ -81,7 +82,7 @@ func initializeTestViewModel(t *testing.T) *FileTreeViewModel {
|
||||
t.Fatalf("%s: unable to build cache: %+v", t.Name(), err)
|
||||
}
|
||||
|
||||
Formatting.Selected = color.New(color.ReverseVideo, color.Bold).SprintFunc()
|
||||
format.Selected = color.New(color.ReverseVideo, color.Bold).SprintFunc()
|
||||
|
||||
treeStack, err := filetree.StackTreeRange(result.RefTrees, 0, 0)
|
||||
if err != nil {
|
||||
@ -94,7 +95,7 @@ func initializeTestViewModel(t *testing.T) *FileTreeViewModel {
|
||||
return vm
|
||||
}
|
||||
|
||||
func runTestCase(t *testing.T, vm *FileTreeViewModel, width, height int, filterRegex *regexp.Regexp) {
|
||||
func runTestCase(t *testing.T, vm *FileTree, width, height int, filterRegex *regexp.Regexp) {
|
||||
err := vm.Update(filterRegex, width, height)
|
||||
if err != nil {
|
||||
t.Errorf("failed to update viewmodel: %v", err)
|
||||
@ -105,7 +106,7 @@ func runTestCase(t *testing.T, vm *FileTreeViewModel, width, height int, filterR
|
||||
t.Errorf("failed to render viewmodel: %v", err)
|
||||
}
|
||||
|
||||
assertTestData(t, vm.mainBuf.Bytes())
|
||||
assertTestData(t, vm.Buffer.Bytes())
|
||||
}
|
||||
|
||||
func checkError(t *testing.T, err error, message string) {
|
||||
@ -152,7 +153,7 @@ func TestFileTreeDirCollapse(t *testing.T) {
|
||||
vm.ShowAttributes = true
|
||||
|
||||
// collapse /bin
|
||||
err := vm.toggleCollapse(nil)
|
||||
err := vm.ToggleCollapse(nil)
|
||||
checkError(t, err, "unable to collapse /bin")
|
||||
|
||||
moved := vm.CursorDown()
|
||||
@ -166,7 +167,7 @@ func TestFileTreeDirCollapse(t *testing.T) {
|
||||
}
|
||||
|
||||
// collapse /etc
|
||||
err = vm.toggleCollapse(nil)
|
||||
err = vm.ToggleCollapse(nil)
|
||||
checkError(t, err, "unable to collapse /etc")
|
||||
|
||||
runTestCase(t, vm, width, height, nil)
|
||||
@ -179,7 +180,7 @@ func TestFileTreeDirCollapseAll(t *testing.T) {
|
||||
vm.Setup(0, height)
|
||||
vm.ShowAttributes = true
|
||||
|
||||
err := vm.toggleCollapseAll()
|
||||
err := vm.ToggleCollapseAll()
|
||||
checkError(t, err, "unable to collapse all dir")
|
||||
|
||||
runTestCase(t, vm, width, height, nil)
|
||||
@ -193,13 +194,13 @@ func TestFileTreeSelectLayer(t *testing.T) {
|
||||
vm.ShowAttributes = true
|
||||
|
||||
// collapse /bin
|
||||
err := vm.toggleCollapse(nil)
|
||||
err := vm.ToggleCollapse(nil)
|
||||
checkError(t, err, "unable to collapse /bin")
|
||||
|
||||
// select the next layer, compareMode = layer
|
||||
err = vm.setTreeByLayer(0, 0, 1, 1)
|
||||
err = vm.SetTreeByLayer(0, 0, 1, 1)
|
||||
if err != nil {
|
||||
t.Errorf("unable to setTreeByLayer: %v", err)
|
||||
t.Errorf("unable to SetTreeByLayer: %v", err)
|
||||
}
|
||||
runTestCase(t, vm, width, height, nil)
|
||||
}
|
||||
@ -212,12 +213,12 @@ func TestFileShowAggregateChanges(t *testing.T) {
|
||||
vm.ShowAttributes = true
|
||||
|
||||
// collapse /bin
|
||||
err := vm.toggleCollapse(nil)
|
||||
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")
|
||||
err = vm.SetTreeByLayer(0, 0, 1, 13)
|
||||
checkError(t, err, "unable to SetTreeByLayer")
|
||||
|
||||
runTestCase(t, vm, width, height, nil)
|
||||
}
|
||||
@ -274,7 +275,7 @@ func TestFileTreeDirCursorRight(t *testing.T) {
|
||||
vm.ShowAttributes = true
|
||||
|
||||
// collapse /bin
|
||||
err := vm.toggleCollapse(nil)
|
||||
err := vm.ToggleCollapse(nil)
|
||||
checkError(t, err, "unable to collapse /bin")
|
||||
|
||||
moved := vm.CursorDown()
|
||||
@ -288,7 +289,7 @@ func TestFileTreeDirCursorRight(t *testing.T) {
|
||||
}
|
||||
|
||||
// collapse /etc
|
||||
err = vm.toggleCollapse(nil)
|
||||
err = vm.ToggleCollapse(nil)
|
||||
checkError(t, err, "unable to collapse /etc")
|
||||
|
||||
// expand /etc
|
||||
@ -321,23 +322,23 @@ func TestFileTreeHideAddedRemovedModified(t *testing.T) {
|
||||
vm.ShowAttributes = true
|
||||
|
||||
// collapse /bin
|
||||
err := vm.toggleCollapse(nil)
|
||||
err := vm.ToggleCollapse(nil)
|
||||
checkError(t, err, "unable to collapse /bin")
|
||||
|
||||
// select the 7th layer, compareMode = layer
|
||||
err = vm.setTreeByLayer(0, 0, 1, 7)
|
||||
err = vm.SetTreeByLayer(0, 0, 1, 7)
|
||||
if err != nil {
|
||||
t.Errorf("unable to setTreeByLayer: %v", err)
|
||||
t.Errorf("unable to SetTreeByLayer: %v", err)
|
||||
}
|
||||
|
||||
// hide added files
|
||||
vm.toggleShowDiffType(filetree.Added)
|
||||
vm.ToggleShowDiffType(filetree.Added)
|
||||
|
||||
// hide modified files
|
||||
vm.toggleShowDiffType(filetree.Modified)
|
||||
vm.ToggleShowDiffType(filetree.Modified)
|
||||
|
||||
// hide removed files
|
||||
vm.toggleShowDiffType(filetree.Removed)
|
||||
vm.ToggleShowDiffType(filetree.Removed)
|
||||
|
||||
runTestCase(t, vm, width, height, nil)
|
||||
}
|
||||
@ -350,17 +351,17 @@ func TestFileTreeHideUnmodified(t *testing.T) {
|
||||
vm.ShowAttributes = true
|
||||
|
||||
// collapse /bin
|
||||
err := vm.toggleCollapse(nil)
|
||||
err := vm.ToggleCollapse(nil)
|
||||
checkError(t, err, "unable to collapse /bin")
|
||||
|
||||
// select the 7th layer, compareMode = layer
|
||||
err = vm.setTreeByLayer(0, 0, 1, 7)
|
||||
err = vm.SetTreeByLayer(0, 0, 1, 7)
|
||||
if err != nil {
|
||||
t.Errorf("unable to setTreeByLayer: %v", err)
|
||||
t.Errorf("unable to SetTreeByLayer: %v", err)
|
||||
}
|
||||
|
||||
// hide unmodified files
|
||||
vm.toggleShowDiffType(filetree.Unmodified)
|
||||
vm.ToggleShowDiffType(filetree.Unmodified)
|
||||
|
||||
runTestCase(t, vm, width, height, nil)
|
||||
}
|
||||
@ -373,17 +374,17 @@ func TestFileTreeHideTypeWithFilter(t *testing.T) {
|
||||
vm.ShowAttributes = true
|
||||
|
||||
// collapse /bin
|
||||
err := vm.toggleCollapse(nil)
|
||||
err := vm.ToggleCollapse(nil)
|
||||
checkError(t, err, "unable to collapse /bin")
|
||||
|
||||
// select the 7th layer, compareMode = layer
|
||||
err = vm.setTreeByLayer(0, 0, 1, 7)
|
||||
err = vm.SetTreeByLayer(0, 0, 1, 7)
|
||||
if err != nil {
|
||||
t.Errorf("unable to setTreeByLayer: %v", err)
|
||||
t.Errorf("unable to SetTreeByLayer: %v", err)
|
||||
}
|
||||
|
||||
// hide added files
|
||||
vm.toggleShowDiffType(filetree.Added)
|
||||
vm.ToggleShowDiffType(filetree.Added)
|
||||
|
||||
regex, err := regexp.Compile("saved")
|
||||
if err != nil {
|
10
runtime/ui/viewmodel/layer_selection.go
Normal file
10
runtime/ui/viewmodel/layer_selection.go
Normal file
@ -0,0 +1,10 @@
|
||||
package viewmodel
|
||||
|
||||
import (
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
)
|
||||
|
||||
type LayerSelection struct {
|
||||
Layer *image.Layer
|
||||
BottomTreeStart, BottomTreeStop, TopTreeStart, TopTreeStop int
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user