diff options
Diffstat (limited to 'runtime/ui')
22 files changed, 3278 insertions, 0 deletions
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 { + 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 + Update() + Render() + return nil +} + +// toggleShowDiffType will show/hide the selected DiffType in the filetree pane. +func (controller *FileTreeController) toggleShowDiffType(diffType filetree.DiffType) error { + controller.vm.toggleShowDiffType(diffType) + // we need to render the changes to the status pane as well + Update() + Render() + return nil +} + +// filterRegex will return a regular expression object to match the user's filter input. +func filterRegex() *regexp.Regexp { + if Controllers.Filter == nil || Controllers.Filter.view == nil { + return nil + } + filterString := strings.TrimSpace(Controllers.Filter.view.Buffer()) + if len(filterString) == 0 { + return nil + } + + regex, err := regexp.Compile(filterString) + if err != nil { + return nil + } + + return regex +} + +// onLayoutChange is called by the UI framework to inform the view-model of the new screen dimensions +func (controller *FileTreeController) onLayoutChange(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() + _ = controller.vm.Render() + _, _ = fmt.Fprint(controller.view, controller.vm.mainBuf.String()) + + return nil + }) + return nil +} + +// KeyHelp indicates all the possible actions a user can take while the current pane is selected. +func (controller *FileTreeController) KeyHelp() string { + return renderStatusOption(controller.keybindingToggleCollapse[0].String(), "Collapse dir", false) + + renderStatusOption(controller.keybindingToggleCollapseAll[0].String(), "Collapse all dir", false) + + renderStatusOption(controller.keybindingToggleAdded[0].String(), "Added", !controller.vm.HiddenDiffTypes[filetree.Added]) + + renderStatusOption(controller.keybindingToggleRemoved[0].String(), "Removed", !controller.vm.HiddenDiffTypes[filetree.Removed]) + + renderStatusOption(controller.keybindingToggleModified[0].String(), "Modified", !controller.vm.HiddenDiffTypes[filetree.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/filetree_viewmodel.go b/runtime/ui/filetree_viewmodel.go new file mode 100644 index 0000000..46e9af5 --- /dev/null +++ b/runtime/ui/filetree_viewmodel.go @@ -0,0 +1,425 @@ +package ui + +import ( + "bytes" + "fmt" + "regexp" + "strings" + + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/wagoodman/dive/utils" + + "github.com/lunixbochs/vtclean" + "github.com/wagoodman/dive/dive/filetree" +) + +// FileTreeViewModel holds the UI objects and data models for populating the right pane. Specifically the pane that +// shows selected layer or aggregate file ASCII tree. +type FileTreeViewModel struct { + ModelTree *filetree.FileTree + ViewTree *filetree.FileTree + RefTrees []*filetree.FileTree + cache filetree.TreeCache + + CollapseAll bool + ShowAttributes bool + HiddenDiffTypes []bool + TreeIndex int + bufferIndex int + bufferIndexLowerBound int + + refHeight int + refWidth int + + mainBuf bytes.Buffer +} + +// NewFileTreeController creates a new view object attached the the global [gocui] screen object. +func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (treeViewModel *FileTreeViewModel) { + treeViewModel = new(FileTreeViewModel) + + // populate main fields + treeViewModel.ShowAttributes = viper.GetBool("filetree.show-attributes") + treeViewModel.CollapseAll = viper.GetBool("filetree.collapse-dir") + treeViewModel.ModelTree = tree + treeViewModel.RefTrees = refTrees + treeViewModel.cache = cache + treeViewModel.HiddenDiffTypes = make([]bool, 4) + + hiddenTypes := viper.GetStringSlice("diff.hide") + for _, hType := range hiddenTypes { + switch t := strings.ToLower(hType); t { + case "added": + treeViewModel.HiddenDiffTypes[filetree.Added] = true + case "removed": + treeViewModel.HiddenDiffTypes[filetree.Removed] = true + case "modified": + treeViewModel.HiddenDiffTypes[filetree.Modified] = true + case "unmodified": + treeViewModel.HiddenDiffTypes[filetree.Unmodified] = true + default: + utils.PrintAndExit(fmt.Sprintf("unknown diff.hide value: %s", t)) + } + } + + return treeViewModel +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (vm *FileTreeViewModel) Setup(lowerBound, height int) { + vm.bufferIndexLowerBound = lowerBound + vm.refHeight = height +} + +// height returns the current height and considers the header +func (vm *FileTreeViewModel) height() int { + if vm.ShowAttributes { + return vm.refHeight - 1 + } + return vm.refHeight +} + +// bufferIndexUpperBound returns the current upper bounds for the view +func (vm *FileTreeViewModel) bufferIndexUpperBound() int { + return vm.bufferIndexLowerBound + vm.height() +} + +// IsVisible indicates if the file tree view pane is currently initialized +func (vm *FileTreeViewModel) IsVisible() bool { + 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() { + vm.TreeIndex = 0 + vm.bufferIndex = 0 + vm.bufferIndexLowerBound = 0 +} + +// setTreeByLayer populates the view model by stacking the indicated image layer file trees. +func (vm *FileTreeViewModel) setTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { + if topTreeStop > len(vm.RefTrees)-1 { + return fmt.Errorf("invalid layer index given: %d of %d", topTreeStop, len(vm.RefTrees)-1) + } + newTree := vm.cache.Get(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) + + // preserve vm state on copy + visitor := func(node *filetree.FileNode) error { + newNode, err := newTree.GetNode(node.Path()) + if err == nil { + newNode.Data.ViewInfo = node.Data.ViewInfo + } + return nil + } + err := vm.ModelTree.VisitDepthChildFirst(visitor, nil) + if err != nil { + logrus.Errorf("unable to propagate layer tree: %+v", err) + return err + } + + vm.ModelTree = newTree + return nil +} + +// doCursorUp performs the internal view's buffer adjustments on cursor up. Note: this is independent of the gocui buffer. +func (vm *FileTreeViewModel) CursorUp() bool { + if vm.TreeIndex <= 0 { + return false + } + vm.TreeIndex-- + if vm.TreeIndex < vm.bufferIndexLowerBound { + vm.bufferIndexLowerBound-- + } + if vm.bufferIndex > 0 { + vm.bufferIndex-- + } + return true +} + +// doCursorDown performs the internal view's buffer adjustments on cursor down. Note: this is independent of the gocui buffer. +func (vm *FileTreeViewModel) CursorDown() bool { + if vm.TreeIndex >= vm.ModelTree.VisibleSize() { + return false + } + vm.TreeIndex++ + if vm.TreeIndex > vm.bufferIndexUpperBound() { + vm.bufferIndexLowerBound++ + } + vm.bufferIndex++ + if vm.bufferIndex > vm.height() { + vm.bufferIndex = vm.height() + } + return true +} + +// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree +func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error { + var visitor func(*filetree.FileNode) error + var evaluator func(*filetree.FileNode) bool + var dfsCounter, newIndex int + oldIndex := vm.TreeIndex + currentNode := vm.getAbsPositionNode(filterRegex) + + if currentNode == nil { + return nil + } + parentPath := currentNode.Parent.Path() + + visitor = func(curNode *filetree.FileNode) error { + if strings.Compare(parentPath, curNode.Path()) == 0 { + newIndex = dfsCounter + } + dfsCounter++ + return nil + } + + evaluator = func(curNode *filetree.FileNode) bool { + regexMatch := true + if filterRegex != nil { + match := filterRegex.Find([]byte(curNode.Path())) + regexMatch = match != nil + } + return !curNode.Parent.Data.ViewInfo.Collapsed && !curNode.Data.ViewInfo.Hidden && regexMatch + } + + err := vm.ModelTree.VisitDepthParentFirst(visitor, evaluator) + if err != nil { + logrus.Errorf("could not propagate tree on cursorLeft: %+v", err) + return err + } + + vm.TreeIndex = newIndex + moveIndex := oldIndex - newIndex + if newIndex < vm.bufferIndexLowerBound { + vm.bufferIndexLowerBound = vm.TreeIndex + } + + if vm.bufferIndex > moveIndex { + vm.bufferIndex -= moveIndex + } else { + vm.bufferIndex = 0 + } + + return nil +} + +// CursorRight descends into directory expanding it if needed +func (vm *FileTreeViewModel) CursorRight(filterRegex *regexp.Regexp) error { + node := vm.getAbsPositionNode(filterRegex) + if node == nil { + return nil + } + + if !node.Data.FileInfo.IsDir { + return nil + } + + if len(node.Children) == 0 { + return nil + } + + if node.Data.ViewInfo.Collapsed { + node.Data.ViewInfo.Collapsed = false + } + + vm.TreeIndex++ + if vm.TreeIndex > vm.bufferIndexUpperBound() { + vm.bufferIndexLowerBound++ + } + + vm.bufferIndex++ + if vm.bufferIndex > vm.height() { + vm.bufferIndex = vm.height() + } + + return nil +} + +// PageDown moves to next page putting the cursor on top +func (vm *FileTreeViewModel) PageDown() error { + nextBufferIndexLowerBound := vm.bufferIndexLowerBound + vm.height() + nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height() + + // todo: this work should be saved or passed to render... + treeString := vm.ViewTree.StringBetween(nextBufferIndexLowerBound, nextBufferIndexUpperBound, vm.ShowAttributes) + lines := strings.Split(treeString, "\n") + + newLines := len(lines) - 1 + if vm.height() >= newLines { + nextBufferIndexLowerBound = vm.bufferIndexLowerBound + newLines + } + + vm.bufferIndexLowerBound = nextBufferIndexLowerBound + + if vm.TreeIndex < nextBufferIndexLowerBound { + vm.bufferIndex = 0 + vm.TreeIndex = nextBufferIndexLowerBound + } else { + vm.bufferIndex -= newLines + } + + return nil +} + +// PageUp moves to previous page putting the cursor on top +func (vm *FileTreeViewModel) PageUp() error { + nextBufferIndexLowerBound := vm.bufferIndexLowerBound - vm.height() + nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height() + + // todo: this work should be saved or passed to render... + treeString := vm.ViewTree.StringBetween(nextBufferIndexLowerBound, nextBufferIndexUpperBound, vm.ShowAttributes) + lines := strings.Split(treeString, "\n") + + newLines := len(lines) - 2 + if vm.height() >= newLines { + nextBufferIndexLowerBound = vm.bufferIndexLowerBound - newLines + } + + vm.bufferIndexLowerBound = nextBufferIndexLowerBound + + if vm.TreeIndex > (nextBu |