summaryrefslogtreecommitdiffstats
path: root/runtime/ui
diff options
context:
space:
mode:
Diffstat (limited to 'runtime/ui')
-rw-r--r--runtime/ui/details_controller.go149
-rw-r--r--runtime/ui/filetree_controller.go403
-rw-r--r--runtime/ui/filetree_viewmodel.go425
-rw-r--r--runtime/ui/filetree_viewmodel_test.go385
-rw-r--r--runtime/ui/filter_controller.go113
-rw-r--r--runtime/ui/layer_controller.go313
-rw-r--r--runtime/ui/status_controller.go77
-rw-r--r--runtime/ui/testdata/TestFileShowAggregateChanges.txt36
-rw-r--r--runtime/ui/testdata/TestFileTreeDirCollapse.txt13
-rw-r--r--runtime/ui/testdata/TestFileTreeDirCollapseAll.txt9
-rw-r--r--runtime/ui/testdata/TestFileTreeDirCursorRight.txt22
-rw-r--r--runtime/ui/testdata/TestFileTreeFilterTree.txt7
-rw-r--r--runtime/ui/testdata/TestFileTreeGoCase.txt416
-rw-r--r--runtime/ui/testdata/TestFileTreeHideAddedRemovedModified.txt21
-rw-r--r--runtime/ui/testdata/TestFileTreeHideTypeWithFilter.txt1
-rw-r--r--runtime/ui/testdata/TestFileTreeHideUnmodified.txt10
-rw-r--r--runtime/ui/testdata/TestFileTreeNoAttributes.txt416
-rw-r--r--runtime/ui/testdata/TestFileTreePageDown.txt11
-rw-r--r--runtime/ui/testdata/TestFileTreePageUp.txt11
-rw-r--r--runtime/ui/testdata/TestFileTreeRestrictedHeight.txt22
-rw-r--r--runtime/ui/testdata/TestFileTreeSelectLayer.txt23
-rw-r--r--runtime/ui/ui.go395
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