diff --git a/Makefile b/Makefile index ac291e5..18d3786 100644 --- a/Makefile +++ b/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 diff --git a/dive/image/podman/resolver_notlinux.go b/dive/image/podman/resolver_notlinux.go index 49b959a..8dfdd22 100644 --- a/dive/image/podman/resolver_notlinux.go +++ b/dive/image/podman/resolver_notlinux.go @@ -9,7 +9,7 @@ import ( type resolver struct{} -func NewResolver() *resolver { +func NewResolverFromEngine() *resolver { return &resolver{} } diff --git a/runtime/run.go b/runtime/run.go index 0dbcfa8..3da39ce 100644 --- a/runtime/run.go +++ b/runtime/run.go @@ -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 { diff --git a/runtime/ui/app.go b/runtime/ui/app.go new file mode 100644 index 0000000..ec7f5ad --- /dev/null +++ b/runtime/ui/app.go @@ -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 +} diff --git a/runtime/ui/controller.go b/runtime/ui/controller.go new file mode 100644 index 0000000..0eef2c2 --- /dev/null +++ b/runtime/ui/controller.go @@ -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() +} diff --git a/runtime/ui/details_controller.go b/runtime/ui/details_controller.go deleted file mode 100644 index 0b9de63..0000000 --- a/runtime/ui/details_controller.go +++ /dev/null @@ -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" -} diff --git a/runtime/ui/filetree_controller.go b/runtime/ui/filetree_controller.go deleted file mode 100644 index 16a72b2..0000000 --- a/runtime/ui/filetree_controller.go +++ /dev/null @@ -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) -} diff --git a/runtime/ui/filter_controller.go b/runtime/ui/filter_controller.go deleted file mode 100644 index bd740b3..0000000 --- a/runtime/ui/filter_controller.go +++ /dev/null @@ -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 ") -} diff --git a/runtime/ui/format/format.go b/runtime/ui/format/format.go new file mode 100644 index 0000000..b3e891e --- /dev/null +++ b/runtime/ui/format/format.go @@ -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+" ") + } +} diff --git a/runtime/ui/key/binding.go b/runtime/ui/key/binding.go new file mode 100644 index 0000000..40533b4 --- /dev/null +++ b/runtime/ui/key/binding.go @@ -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()) +} diff --git a/runtime/ui/layer_controller.go b/runtime/ui/layer_controller.go deleted file mode 100644 index 5695bb5..0000000 --- a/runtime/ui/layer_controller.go +++ /dev/null @@ -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) -} diff --git a/runtime/ui/layout_manager.go b/runtime/ui/layout_manager.go new file mode 100644 index 0000000..72f8c26 --- /dev/null +++ b/runtime/ui/layout_manager.go @@ -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 +} diff --git a/runtime/ui/status_controller.go b/runtime/ui/status_controller.go deleted file mode 100644 index 8e590df..0000000 --- a/runtime/ui/status_controller.go +++ /dev/null @@ -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()) -} diff --git a/runtime/ui/ui.go b/runtime/ui/ui.go deleted file mode 100644 index 3b2075a..0000000 --- a/runtime/ui/ui.go +++ /dev/null @@ -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 -} diff --git a/runtime/ui/view/cursor.go b/runtime/ui/view/cursor.go new file mode 100644 index 0000000..7cd5bcf --- /dev/null +++ b/runtime/ui/view/cursor.go @@ -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 +} diff --git a/runtime/ui/view/details.go b/runtime/ui/view/details.go new file mode 100644 index 0000000..9cb5ce0 --- /dev/null +++ b/runtime/ui/view/details.go @@ -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" +} diff --git a/runtime/ui/view/filetree.go b/runtime/ui/view/filetree.go new file mode 100644 index 0000000..3185be4 --- /dev/null +++ b/runtime/ui/view/filetree.go @@ -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 +} diff --git a/runtime/ui/view/filter.go b/runtime/ui/view/filter.go new file mode 100644 index 0000000..e32957a --- /dev/null +++ b/runtime/ui/view/filter.go @@ -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 ") +} diff --git a/runtime/ui/view/layer.go b/runtime/ui/view/layer.go new file mode 100644 index 0000000..5d3c678 --- /dev/null +++ b/runtime/ui/view/layer.go @@ -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 +} diff --git a/runtime/ui/view/renderer.go b/runtime/ui/view/renderer.go new file mode 100644 index 0000000..c3fadf5 --- /dev/null +++ b/runtime/ui/view/renderer.go @@ -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 +} diff --git a/runtime/ui/view/status.go b/runtime/ui/view/status.go new file mode 100644 index 0000000..bdd0363 --- /dev/null +++ b/runtime/ui/view/status.go @@ -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 +} diff --git a/runtime/ui/filetree_viewmodel.go b/runtime/ui/viewmodel/filetree.go similarity index 83% rename from runtime/ui/filetree_viewmodel.go rename to runtime/ui/viewmodel/filetree.go index 18ebfdd..75cb469 100644 --- a/runtime/ui/filetree_viewmodel.go +++ b/runtime/ui/viewmodel/filetree.go @@ -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 diff --git a/runtime/ui/filetree_viewmodel_test.go b/runtime/ui/viewmodel/filetree_test.go similarity index 85% rename from runtime/ui/filetree_viewmodel_test.go rename to runtime/ui/viewmodel/filetree_test.go index df0e0ca..c90f0d7 100644 --- a/runtime/ui/filetree_viewmodel_test.go +++ b/runtime/ui/viewmodel/filetree_test.go @@ -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 { diff --git a/runtime/ui/viewmodel/layer_selection.go b/runtime/ui/viewmodel/layer_selection.go new file mode 100644 index 0000000..47bc5e2 --- /dev/null +++ b/runtime/ui/viewmodel/layer_selection.go @@ -0,0 +1,10 @@ +package viewmodel + +import ( + "github.com/wagoodman/dive/dive/image" +) + +type LayerSelection struct { + Layer *image.Layer + BottomTreeStart, BottomTreeStop, TopTreeStart, TopTreeStop int +} diff --git a/runtime/ui/testdata/TestFileShowAggregateChanges.txt b/runtime/ui/viewmodel/testdata/TestFileShowAggregateChanges.txt similarity index 100% rename from runtime/ui/testdata/TestFileShowAggregateChanges.txt rename to runtime/ui/viewmodel/testdata/TestFileShowAggregateChanges.txt diff --git a/runtime/ui/testdata/TestFileTreeDirCollapse.txt b/runtime/ui/viewmodel/testdata/TestFileTreeDirCollapse.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeDirCollapse.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeDirCollapse.txt diff --git a/runtime/ui/testdata/TestFileTreeDirCollapseAll.txt b/runtime/ui/viewmodel/testdata/TestFileTreeDirCollapseAll.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeDirCollapseAll.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeDirCollapseAll.txt diff --git a/runtime/ui/testdata/TestFileTreeDirCursorRight.txt b/runtime/ui/viewmodel/testdata/TestFileTreeDirCursorRight.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeDirCursorRight.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeDirCursorRight.txt diff --git a/runtime/ui/testdata/TestFileTreeFilterTree.txt b/runtime/ui/viewmodel/testdata/TestFileTreeFilterTree.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeFilterTree.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeFilterTree.txt diff --git a/runtime/ui/testdata/TestFileTreeGoCase.txt b/runtime/ui/viewmodel/testdata/TestFileTreeGoCase.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeGoCase.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeGoCase.txt diff --git a/runtime/ui/testdata/TestFileTreeHideAddedRemovedModified.txt b/runtime/ui/viewmodel/testdata/TestFileTreeHideAddedRemovedModified.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeHideAddedRemovedModified.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeHideAddedRemovedModified.txt diff --git a/runtime/ui/testdata/TestFileTreeHideTypeWithFilter.txt b/runtime/ui/viewmodel/testdata/TestFileTreeHideTypeWithFilter.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeHideTypeWithFilter.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeHideTypeWithFilter.txt diff --git a/runtime/ui/testdata/TestFileTreeHideUnmodified.txt b/runtime/ui/viewmodel/testdata/TestFileTreeHideUnmodified.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeHideUnmodified.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeHideUnmodified.txt diff --git a/runtime/ui/testdata/TestFileTreeNoAttributes.txt b/runtime/ui/viewmodel/testdata/TestFileTreeNoAttributes.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeNoAttributes.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeNoAttributes.txt diff --git a/runtime/ui/testdata/TestFileTreePageDown.txt b/runtime/ui/viewmodel/testdata/TestFileTreePageDown.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreePageDown.txt rename to runtime/ui/viewmodel/testdata/TestFileTreePageDown.txt diff --git a/runtime/ui/testdata/TestFileTreePageUp.txt b/runtime/ui/viewmodel/testdata/TestFileTreePageUp.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreePageUp.txt rename to runtime/ui/viewmodel/testdata/TestFileTreePageUp.txt diff --git a/runtime/ui/testdata/TestFileTreeRestrictedHeight.txt b/runtime/ui/viewmodel/testdata/TestFileTreeRestrictedHeight.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeRestrictedHeight.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeRestrictedHeight.txt diff --git a/runtime/ui/testdata/TestFileTreeSelectLayer.txt b/runtime/ui/viewmodel/testdata/TestFileTreeSelectLayer.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeSelectLayer.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeSelectLayer.txt