diff options
Diffstat (limited to 'runtime')
29 files changed, 3327 insertions, 46 deletions
diff --git a/runtime/ci_evaluator.go b/runtime/ci/evaluator.go index d1b159d..2c3cb93 100644 --- a/runtime/ci_evaluator.go +++ b/runtime/ci/evaluator.go @@ -1,8 +1,10 @@ -package runtime +package ci import ( "fmt" "github.com/dustin/go-humanize" + "github.com/wagoodman/dive/dive/image" + "github.com/wagoodman/dive/utils" "sort" "strconv" "strings" @@ -10,7 +12,6 @@ import ( "github.com/spf13/viper" "github.com/logrusorgru/aurora" - "github.com/wagoodman/dive/image" ) type CiEvaluator struct { @@ -133,7 +134,7 @@ func (ci *CiEvaluator) Evaluate(analysis *image.AnalysisResult) bool { } func (ci *CiEvaluator) Report() { - fmt.Println(title("Inefficient Files:")) + fmt.Println(utils.TitleFormat("Inefficient Files:")) template := "%5s %12s %-s\n" fmt.Printf(template, "Count", "Wasted Space", "File Path") @@ -142,11 +143,11 @@ func (ci *CiEvaluator) Report() { fmt.Println("None") } else { for _, file := range ci.InefficientFiles { - fmt.Printf(template, strconv.Itoa(file.References), humanize.Bytes(uint64(file.SizeBytes)), file.Path) + fmt.Printf(template, strconv.Itoa(file.References), humanize.Bytes(file.SizeBytes), file.Path) } } - fmt.Println(title("Results:")) + fmt.Println(utils.TitleFormat("Results:")) status := "PASS" diff --git a/runtime/ci_evaluator_test.go b/runtime/ci/evaluator_test.go index c7124ad..acd6b9b 100644 --- a/runtime/ci_evaluator_test.go +++ b/runtime/ci/evaluator_test.go @@ -1,16 +1,16 @@ -package runtime +package ci import ( + "github.com/wagoodman/dive/dive/image/docker" "strings" "testing" "github.com/spf13/viper" - "github.com/wagoodman/dive/image" ) func Test_Evaluator(t *testing.T) { - result, err := image.TestLoadDockerImageTar("../.data/test-docker-image.tar") + result, err := docker.TestLoadDockerImageTar("../../.data/test-docker-image.tar") if err != nil { t.Fatalf("Test_Export: unable to fetch analysis: %v", err) } diff --git a/runtime/reference_file.go b/runtime/ci/reference_file.go index bef54eb..c5891c7 100644 --- a/runtime/reference_file.go +++ b/runtime/ci/reference_file.go @@ -1,4 +1,4 @@ -package runtime +package ci type ReferenceFile struct { References int `json:"count"` diff --git a/runtime/ci_rule.go b/runtime/ci/rule.go index d14295a..60b350d 100644 --- a/runtime/ci_rule.go +++ b/runtime/ci/rule.go @@ -1,14 +1,14 @@ -package runtime +package ci import ( "fmt" + "github.com/wagoodman/dive/dive/image" "strconv" "github.com/spf13/viper" "github.com/dustin/go-humanize" "github.com/logrusorgru/aurora" - "github.com/wagoodman/dive/image" ) const ( @@ -25,7 +25,7 @@ type CiRule interface { Key() string Configuration() string Validate() error - Evaluate(*image.AnalysisResult) (RuleStatus, string) + Evaluate(result *image.AnalysisResult) (RuleStatus, string) } type GenericCiRule struct { diff --git a/runtime/export.go b/runtime/export/export.go index e769015..58aa095 100644 --- a/runtime/export.go +++ b/runtime/export/export.go @@ -1,10 +1,9 @@ -package runtime +package export import ( "encoding/json" + "github.com/wagoodman/dive/dive/image" "io/ioutil" - - "github.com/wagoodman/dive/image" ) type export struct { @@ -20,16 +19,22 @@ type exportLayer struct { } type exportImage struct { - SizeBytes uint64 `json:"sizeBytes"` - InefficientBytes uint64 `json:"inefficientBytes"` - EfficiencyScore float64 `json:"efficiencyScore"` - InefficientFiles []ReferenceFile `json:"ReferenceFile"` + SizeBytes uint64 `json:"sizeBytes"` + InefficientBytes uint64 `json:"inefficientBytes"` + EfficiencyScore float64 `json:"efficiencyScore"` + InefficientFiles []exportReferenceFile `json:"exportReferenceFile"` +} + +type exportReferenceFile struct { + References int `json:"count"` + SizeBytes uint64 `json:"sizeBytes"` + Path string `json:"file"` } -func newExport(analysis *image.AnalysisResult) *export { +func NewExport(analysis *image.AnalysisResult) *export { data := export{} data.Layer = make([]exportLayer, len(analysis.Layers)) - data.Image.InefficientFiles = make([]ReferenceFile, len(analysis.Inefficiencies)) + data.Image.InefficientFiles = make([]exportReferenceFile, len(analysis.Inefficiencies)) // export layers in order for revIdx := len(analysis.Layers) - 1; revIdx >= 0; revIdx-- { @@ -51,7 +56,7 @@ func newExport(analysis *image.AnalysisResult) *export { for idx := 0; idx < len(analysis.Inefficiencies); idx++ { fileData := analysis.Inefficiencies[len(analysis.Inefficiencies)-1-idx] - data.Image.InefficientFiles[idx] = ReferenceFile{ + data.Image.InefficientFiles[idx] = exportReferenceFile{ References: len(fileData.Nodes), SizeBytes: uint64(fileData.CumulativeSize), Path: fileData.Path, @@ -65,7 +70,7 @@ func (exp *export) marshal() ([]byte, error) { return json.MarshalIndent(&exp, "", " ") } -func (exp *export) toFile(exportFilePath string) error { +func (exp *export) ToFile(exportFilePath string) error { payload, err := exp.marshal() if err != nil { return err diff --git a/runtime/export_test.go b/runtime/export/export_test.go index 709225c..e3750ed 100644 --- a/runtime/export_test.go +++ b/runtime/export/export_test.go @@ -1,18 +1,17 @@ -package runtime +package export import ( + "github.com/wagoodman/dive/dive/image/docker" "testing" - - "github.com/wagoodman/dive/image" ) func Test_Export(t *testing.T) { - result, err := image.TestLoadDockerImageTar("../.data/test-docker-image.tar") + result, err := docker.TestLoadDockerImageTar("../../.data/test-docker-image.tar") if err != nil { t.Fatalf("Test_Export: unable to fetch analysis: %v", err) } - export := newExport(result) + export := NewExport(result) payload, err := export.marshal() if err != nil { t.Errorf("Test_Export: unable to export analysis: %v", err) @@ -109,7 +108,7 @@ func Test_Export(t *testing.T) { "sizeBytes": 1220598, "inefficientBytes": 32025, "efficiencyScore": 0.9844212134184309, - "ReferenceFile": [ + "exportReferenceFile": [ { "count": 2, "sizeBytes": 12810, diff --git a/runtime/run.go b/runtime/run.go index a427bfa..99c6b7e 100644 --- a/runtime/run.go +++ b/runtime/run.go @@ -2,28 +2,26 @@ package runtime import ( "fmt" + "github.com/wagoodman/dive/dive" + "github.com/wagoodman/dive/runtime/ci" + "github.com/wagoodman/dive/runtime/export" "io/ioutil" "log" "os" "github.com/dustin/go-humanize" - "github.com/logrusorgru/aurora" - "github.com/wagoodman/dive/filetree" - "github.com/wagoodman/dive/image" - "github.com/wagoodman/dive/ui" + "github.com/wagoodman/dive/dive/filetree" + "github.com/wagoodman/dive/dive/image" + "github.com/wagoodman/dive/runtime/ui" "github.com/wagoodman/dive/utils" ) -func title(s string) string { - return aurora.Bold(s).String() -} - func runCi(analysis *image.AnalysisResult, options Options) { fmt.Printf(" efficiency: %2.4f %%\n", analysis.Efficiency*100) fmt.Printf(" wastedBytes: %d bytes (%s)\n", analysis.WastedBytes, humanize.Bytes(analysis.WastedBytes)) fmt.Printf(" userWastedPercent: %2.4f %%\n", analysis.WastedUserPercent*100) - evaluator := NewCiEvaluator(options.CiConfig) + evaluator := ci.NewCiEvaluator(options.CiConfig) pass := evaluator.Evaluate(analysis) evaluator.Report() @@ -63,13 +61,13 @@ func Run(options Options) { doBuild := len(options.BuildArgs) > 0 if doBuild { - fmt.Println(title("Building image...")) + fmt.Println(utils.TitleFormat("Building image...")) options.ImageId = runBuild(options.BuildArgs) } - analyzer := image.GetAnalyzer(options.ImageId) + analyzer := dive.GetAnalyzer(options.ImageId) - fmt.Println(title("Fetching image...") + " (this can take a while with large images)") + fmt.Println(utils.TitleFormat("Fetching image...") + " (this can take a while with large images)") reader, err := analyzer.Fetch() if err != nil { fmt.Printf("cannot fetch image: %v\n", err) @@ -77,7 +75,7 @@ func Run(options Options) { } defer reader.Close() - fmt.Println(title("Parsing image...")) + fmt.Println(utils.TitleFormat("Parsing image...")) err = analyzer.Parse(reader) if err != nil { fmt.Printf("cannot parse image: %v\n", err) @@ -85,9 +83,9 @@ func Run(options Options) { } if doExport { - fmt.Println(title(fmt.Sprintf("Analyzing image... (export to '%s')", options.ExportFile))) + fmt.Println(utils.TitleFormat(fmt.Sprintf("Analyzing image... (export to '%s')", options.ExportFile))) } else { - fmt.Println(title("Analyzing image...")) + fmt.Println(utils.TitleFormat("Analyzing image...")) } result, err := analyzer.Analyze() @@ -97,7 +95,7 @@ func Run(options Options) { } if doExport { - err = newExport(result).toFile(options.ExportFile) + err = export.NewExport(result).ToFile(options.ExportFile) if err != nil { fmt.Printf("cannot write export file: %v\n", err) utils.Exit(1) @@ -111,7 +109,7 @@ func Run(options Options) { utils.Exit(0) } - fmt.Println(title("Building cache...")) + fmt.Println(utils.TitleFormat("Building cache...")) cache := filetree.NewFileTreeCache(result.RefTrees) cache.Build() diff --git a/runtime/ui/details_controller.go b/runtime/ui/details_controller.go new file mode 100644 index 0000000..ab9d7ec --- /dev/null +++ b/runtime/ui/details_controller.go @@ -0,0 +1,149 @@ +package ui + +import ( + "fmt" + "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)) + + _, _ = fmt.Fprintln(controller.header, Formatting.Header(vtclean.Clean(layerHeaderStr, false))) + + // update contents + controller.view.Clear() + _, _ = fmt.Fprintln(controller.view, Formatting.Header("Digest: ")+currentLayer.Id()) + // TODO: add back in with controller model + // fmt.Fprintln(view.view, Formatting.Header("Tar ID: ")+currentLayer.TarId()) + _, _ = fmt.Fprintln(controller.view, Formatting.Header("Command:")) + _, _ = fmt.Fprintln(controller.view, currentLayer.Command()) + + _, _ = fmt.Fprintln(controller.view, "\n"+Formatting.Header(vtclean.Clean(imageHeaderStr, false))) + + _, _ = fmt.Fprintln(controller.view, imageSizeStr) + _, _ = fmt.Fprintln(controller.view, wastedSpaceStr) + _, _ = fmt.Fprintln(controller.view, effStr+"\n") + + _, _ = fmt.Fprintln(controller.view, inefficiencyReport) + return nil + }) + return nil +} + +// KeyHelp indicates all the possible actions a user can take while the current pane is selected (currently does nothing). +func (controller *DetailsController) KeyHelp() string { + return "TBD" +} diff --git a/runtime/ui/filetree_controller.go b/runtime/ui/filetree_controller.go new file mode 100644 index 0000000..90bcba6 --- /dev/null +++ b/runtime/ui/filetree_controller.go @@ -0,0 +1,403 @@ +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) { + controller = new(FileTreeController) + + // populate main fields + controller.Name = name + controller.gui = gui + controller.vm = NewFileTreeViewModel(tree, refTrees, cache) + + var err error + controller.keybindingToggleCollapse, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-collapse-dir")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleCollapseAll, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-collapse-all-dir")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleAttributes, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-filetree-attributes")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleAdded, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-added-files")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleRemoved, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-removed-files")) + if err != nil { + logrus.Error(err) + } + + controller.keybindingToggleModified, err = keybinding.ParseAll(viper.GetString("keybinding.toggle-modified-files")) + if err != nil { + logrus.Error(err) + } + + // 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 +} + +// 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 { |