feat: add support for alternative ordering strategies (#424)

This commit is contained in:
Ian Ray 2023-07-07 07:01:53 -07:00 committed by GitHub
parent d5e8a92968
commit 6f20438ae4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 167 additions and 75 deletions

View file

@ -86,6 +86,7 @@ func initConfig() {
// keybindings: filetree view
viper.SetDefault("keybinding.toggle-collapse-dir", "space")
viper.SetDefault("keybinding.toggle-collapse-all-dir", "ctrl+space")
viper.SetDefault("keybinding.toggle-sort-order", "ctrl+o")
viper.SetDefault("keybinding.toggle-filetree-attributes", "ctrl+b")
viper.SetDefault("keybinding.toggle-added-files", "ctrl+a")
viper.SetDefault("keybinding.toggle-removed-files", "ctrl+r")

View file

@ -79,7 +79,7 @@ func Efficiency(trees []*FileTree) (float64, EfficiencySlice) {
}
if previousTreeNode.Data.FileInfo.IsDir {
err = previousTreeNode.VisitDepthChildFirst(sizer, nil)
err = previousTreeNode.VisitDepthChildFirst(sizer, nil, nil)
if err != nil {
logrus.Errorf("unable to propagate whiteout dir: %+v", err)
return err

View file

@ -3,7 +3,6 @@ package filetree
import (
"archive/tar"
"fmt"
"sort"
"strings"
"github.com/dustin/go-humanize"
@ -27,6 +26,7 @@ var diffTypeColor = map[DiffType]*color.Color{
type FileNode struct {
Tree *FileTree
Parent *FileNode
Size int64 // memoized total size of file or directory
Name string
Data NodeData
Children map[string]*FileNode
@ -39,6 +39,7 @@ func NewNode(parent *FileNode, name string, data FileInfo) (node *FileNode) {
node.Name = name
node.Data = *NewNodeData()
node.Data.FileInfo = *data.Copy()
node.Size = -1 // signal lazy load later
node.Children = make(map[string]*FileNode)
node.Parent = parent
@ -149,41 +150,49 @@ func (node *FileNode) MetadataString() string {
group := node.Data.FileInfo.Gid
userGroup := fmt.Sprintf("%d:%d", user, group)
var sizeBytes int64
if node.IsLeaf() {
sizeBytes = node.Data.FileInfo.Size
} else {
sizer := func(curNode *FileNode) error {
// don't include file sizes of children that have been removed (unless the node in question is a removed dir,
// then show the accumulated size of removed files)
if curNode.Data.DiffType != Removed || node.Data.DiffType == Removed {
sizeBytes += curNode.Data.FileInfo.Size
}
return nil
}
err := node.VisitDepthChildFirst(sizer, nil)
if err != nil {
logrus.Errorf("unable to propagate node for metadata: %+v", err)
}
}
// don't include file sizes of children that have been removed (unless the node in question is a removed dir,
// then show the accumulated size of removed files)
sizeBytes := node.GetSize()
size := humanize.Bytes(uint64(sizeBytes))
return diffTypeColor[node.Data.DiffType].Sprint(fmt.Sprintf(AttributeFormat, dir, fileMode, userGroup, size))
}
// VisitDepthChildFirst iterates a tree depth-first (starting at this FileNode), evaluating the deepest depths first (visit on bubble up)
func (node *FileNode) VisitDepthChildFirst(visitor Visitor, evaluator VisitEvaluator) error {
var keys []string
for key := range node.Children {
keys = append(keys, key)
func (node *FileNode) GetSize() int64 {
if 0 <= node.Size {
return node.Size
}
sort.Strings(keys)
var sizeBytes int64
if node.IsLeaf() {
sizeBytes = node.Data.FileInfo.Size
} else {
sizer := func(curNode *FileNode) error {
if curNode.Data.DiffType != Removed || node.Data.DiffType == Removed {
sizeBytes += curNode.Data.FileInfo.Size
}
return nil
}
err := node.VisitDepthChildFirst(sizer, nil, nil)
if err != nil {
logrus.Errorf("unable to propagate node for metadata: %+v", err)
}
}
node.Size = sizeBytes
return node.Size
}
// VisitDepthChildFirst iterates a tree depth-first (starting at this FileNode), evaluating the deepest depths first (visit on bubble up)
func (node *FileNode) VisitDepthChildFirst(visitor Visitor, evaluator VisitEvaluator, sorter OrderStrategy) error {
if sorter == nil {
sorter = GetSortOrderStrategy(ByName)
}
keys := sorter.orderKeys(node.Children)
for _, name := range keys {
child := node.Children[name]
err := child.VisitDepthChildFirst(visitor, evaluator)
err := child.VisitDepthChildFirst(visitor, evaluator, sorter)
if err != nil {
return err
}
@ -199,7 +208,7 @@ func (node *FileNode) VisitDepthChildFirst(visitor Visitor, evaluator VisitEvalu
}
// VisitDepthParentFirst iterates a tree depth-first (starting at this FileNode), evaluating the shallowest depths first (visit while sinking down)
func (node *FileNode) VisitDepthParentFirst(visitor Visitor, evaluator VisitEvaluator) error {
func (node *FileNode) VisitDepthParentFirst(visitor Visitor, evaluator VisitEvaluator, sorter OrderStrategy) error {
var err error
doVisit := evaluator != nil && evaluator(node) || evaluator == nil
@ -216,14 +225,13 @@ func (node *FileNode) VisitDepthParentFirst(visitor Visitor, evaluator VisitEval
}
}
var keys []string
for key := range node.Children {
keys = append(keys, key)
if sorter == nil {
sorter = GetSortOrderStrategy(ByName)
}
sort.Strings(keys)
keys := sorter.orderKeys(node.Children)
for _, name := range keys {
child := node.Children[name]
err = child.VisitDepthParentFirst(visitor, evaluator)
err = child.VisitDepthParentFirst(visitor, evaluator, sorter)
if err != nil {
return err
}

View file

@ -3,7 +3,6 @@ package filetree
import (
"fmt"
"path"
"sort"
"strings"
"github.com/google/uuid"
@ -24,11 +23,12 @@ const (
// FileTree represents a set of files, directories, and their relations.
type FileTree struct {
Root *FileNode
Size int
FileSize uint64
Name string
Id uuid.UUID
Root *FileNode
Size int
FileSize uint64
Name string
Id uuid.UUID
SortOrder SortOrder
}
// NewFileTree creates an empty FileTree
@ -39,6 +39,7 @@ func NewFileTree() (tree *FileTree) {
tree.Root.Tree = tree
tree.Root.Children = make(map[string]*FileNode)
tree.Id = uuid.New()
tree.SortOrder = ByName
return tree
}
@ -67,12 +68,8 @@ func (tree *FileTree) renderStringTreeBetween(startRow, stopRow int, showAttribu
currentParams, paramsToVisit = paramsToVisit[0], paramsToVisit[1:]
// take note of the next nodes to visit later
var keys []string
for key := range currentParams.node.Children {
keys = append(keys, key)
}
// we should always visit nodes in order
sort.Strings(keys)
sorter := GetSortOrderStrategy(tree.SortOrder)
keys := sorter.orderKeys(currentParams.node.Children)
var childParams = make([]renderParams, 0)
for idx, name := range keys {
@ -174,6 +171,7 @@ func (tree *FileTree) Copy() *FileTree {
newTree.Size = tree.Size
newTree.FileSize = tree.FileSize
newTree.Root = tree.Root.Copy(newTree.Root)
newTree.SortOrder = tree.SortOrder
// update the tree pointers
err := newTree.VisitDepthChildFirst(func(node *FileNode) error {
@ -196,12 +194,14 @@ type VisitEvaluator func(*FileNode) bool
// VisitDepthChildFirst iterates the given tree depth-first, evaluating the deepest depths first (visit on bubble up)
func (tree *FileTree) VisitDepthChildFirst(visitor Visitor, evaluator VisitEvaluator) error {
return tree.Root.VisitDepthChildFirst(visitor, evaluator)
sorter := GetSortOrderStrategy(tree.SortOrder)
return tree.Root.VisitDepthChildFirst(visitor, evaluator, sorter)
}
// VisitDepthParentFirst iterates the given tree depth-first, evaluating the shallowest depths first (visit while sinking down)
func (tree *FileTree) VisitDepthParentFirst(visitor Visitor, evaluator VisitEvaluator) error {
return tree.Root.VisitDepthParentFirst(visitor, evaluator)
sorter := GetSortOrderStrategy(tree.SortOrder)
return tree.Root.VisitDepthParentFirst(visitor, evaluator, sorter)
}
// Stack takes two trees and combines them together. This is done by "stacking" the given tree on top of the owning tree.

View file

@ -0,0 +1,61 @@
package filetree
import (
"sort"
)
type SortOrder int
const (
ByName = iota
BySizeDesc
NumSortOrderConventions
)
type OrderStrategy interface {
orderKeys(files map[string]*FileNode) []string
}
func GetSortOrderStrategy(sortOrder SortOrder) OrderStrategy {
switch sortOrder {
case ByName:
return orderByNameStrategy{}
case BySizeDesc:
return orderBySizeDescStrategy{}
}
return orderByNameStrategy{}
}
type orderByNameStrategy struct{}
func (orderByNameStrategy) orderKeys(files map[string]*FileNode) []string {
var keys []string
for key := range files {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
type orderBySizeDescStrategy struct{}
func (orderBySizeDescStrategy) orderKeys(files map[string]*FileNode) []string {
var keys []string
for key := range files {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
ki, kj := keys[i], keys[j]
ni, nj := files[ki], files[kj]
if ni.GetSize() == nj.GetSize() {
return ki < kj
}
return ni.GetSize() > nj.GetSize()
})
return keys
}

View file

@ -24,7 +24,7 @@ type FileTree struct {
gui *gocui.Gui
view *gocui.View
header *gocui.View
vm *viewmodel.FileTree
vm *viewmodel.FileTreeViewModel
title string
filterRegex *regexp.Regexp
@ -98,6 +98,11 @@ func (v *FileTree) Setup(view, header *gocui.View) error {
OnAction: v.toggleCollapseAll,
Display: "Collapse all dir",
},
{
ConfigKeys: []string{"keybinding.toggle-sort-order"},
OnAction: v.toggleSortOrder,
Display: "Toggle sort order",
},
{
ConfigKeys: []string{"keybinding.toggle-added-files"},
OnAction: func() error { return v.toggleShowDiffType(filetree.Added) },
@ -288,6 +293,16 @@ func (v *FileTree) toggleCollapseAll() error {
return v.Render()
}
func (v *FileTree) toggleSortOrder() error {
err := v.vm.ToggleSortOrder()
if err != nil {
return err
}
v.resetCursor()
_ = v.Update()
return v.Render()
}
func (v *FileTree) toggleWrapTree() error {
v.view.Wrap = !v.view.Wrap
return nil

View file

@ -16,7 +16,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 FileTree struct {
type FileTreeViewModel struct {
ModelTree *filetree.FileTree
ViewTree *filetree.FileTree
RefTrees []*filetree.FileTree
@ -39,8 +39,8 @@ type FileTree struct {
}
// NewFileTreeViewModel creates a new view object attached the the global [gocui] screen object.
func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.Comparer) (treeViewModel *FileTree, err error) {
treeViewModel = new(FileTree)
func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.Comparer) (treeViewModel *FileTreeViewModel, err error) {
treeViewModel = new(FileTreeViewModel)
// populate main fields
treeViewModel.ShowAttributes = viper.GetBool("filetree.show-attributes")
@ -71,13 +71,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 *FileTree) Setup(lowerBound, height int) {
func (vm *FileTreeViewModel) Setup(lowerBound, height int) {
vm.bufferIndexLowerBound = lowerBound
vm.refHeight = height
}
// height returns the current height and considers the header
func (vm *FileTree) height() int {
func (vm *FileTreeViewModel) height() int {
if vm.ShowAttributes {
return vm.refHeight - 1
}
@ -85,24 +85,24 @@ func (vm *FileTree) height() int {
}
// bufferIndexUpperBound returns the current upper bounds for the view
func (vm *FileTree) bufferIndexUpperBound() int {
func (vm *FileTreeViewModel) bufferIndexUpperBound() int {
return vm.bufferIndexLowerBound + vm.height()
}
// IsVisible indicates if the file tree view pane is currently initialized
func (vm *FileTree) IsVisible() bool {
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 *FileTree) ResetCursor() {
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 *FileTree) SetTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error {
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)
}
@ -131,7 +131,7 @@ func (vm *FileTree) SetTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart
}
// doCursorUp performs the internal view's buffer adjustments on cursor up. Note: this is independent of the gocui buffer.
func (vm *FileTree) CursorUp() bool {
func (vm *FileTreeViewModel) CursorUp() bool {
if vm.TreeIndex <= 0 {
return false
}
@ -146,7 +146,7 @@ func (vm *FileTree) CursorUp() bool {
}
// doCursorDown performs the internal view's buffer adjustments on cursor down. Note: this is independent of the gocui buffer.
func (vm *FileTree) CursorDown() bool {
func (vm *FileTreeViewModel) CursorDown() bool {
if vm.TreeIndex >= vm.ModelTree.VisibleSize() {
return false
}
@ -162,7 +162,7 @@ func (vm *FileTree) CursorDown() bool {
}
// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree
func (vm *FileTree) CursorLeft(filterRegex *regexp.Regexp) error {
func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error {
var visitor func(*filetree.FileNode) error
var evaluator func(*filetree.FileNode) bool
var dfsCounter, newIndex int
@ -213,7 +213,7 @@ func (vm *FileTree) CursorLeft(filterRegex *regexp.Regexp) error {
}
// CursorRight descends into directory expanding it if needed
func (vm *FileTree) CursorRight(filterRegex *regexp.Regexp) error {
func (vm *FileTreeViewModel) CursorRight(filterRegex *regexp.Regexp) error {
node := vm.getAbsPositionNode(filterRegex)
if node == nil {
return nil
@ -245,7 +245,7 @@ func (vm *FileTree) CursorRight(filterRegex *regexp.Regexp) error {
}
// PageDown moves to next page putting the cursor on top
func (vm *FileTree) PageDown() error {
func (vm *FileTreeViewModel) PageDown() error {
nextBufferIndexLowerBound := vm.bufferIndexLowerBound + vm.height()
nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height()
@ -271,7 +271,7 @@ func (vm *FileTree) PageDown() error {
}
// PageUp moves to previous page putting the cursor on top
func (vm *FileTree) PageUp() error {
func (vm *FileTreeViewModel) PageUp() error {
nextBufferIndexLowerBound := vm.bufferIndexLowerBound - vm.height()
nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height()
@ -296,7 +296,7 @@ func (vm *FileTree) PageUp() error {
}
// getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode.
func (vm *FileTree) getAbsPositionNode(filterRegex *regexp.Regexp) (node *filetree.FileNode) {
func (vm *FileTreeViewModel) getAbsPositionNode(filterRegex *regexp.Regexp) (node *filetree.FileNode) {
var visitor func(*filetree.FileNode) error
var evaluator func(*filetree.FileNode) bool
var dfsCounter int
@ -327,7 +327,7 @@ func (vm *FileTree) getAbsPositionNode(filterRegex *regexp.Regexp) (node *filetr
}
// ToggleCollapse will collapse/expand the selected FileNode.
func (vm *FileTree) ToggleCollapse(filterRegex *regexp.Regexp) error {
func (vm *FileTreeViewModel) ToggleCollapse(filterRegex *regexp.Regexp) error {
node := vm.getAbsPositionNode(filterRegex)
if node != nil && node.Data.FileInfo.IsDir {
node.Data.ViewInfo.Collapsed = !node.Data.ViewInfo.Collapsed
@ -336,7 +336,7 @@ func (vm *FileTree) ToggleCollapse(filterRegex *regexp.Regexp) error {
}
// ToggleCollapseAll will collapse/expand the all directories.
func (vm *FileTree) ToggleCollapseAll() error {
func (vm *FileTreeViewModel) ToggleCollapseAll() error {
vm.CollapseAll = !vm.CollapseAll
visitor := func(curNode *filetree.FileNode) error {
@ -356,7 +356,14 @@ func (vm *FileTree) ToggleCollapseAll() error {
return nil
}
func (vm *FileTree) ConstrainLayout() {
// ToggleSortOrder will toggle the sort order in which files are displayed
func (vm *FileTreeViewModel) ToggleSortOrder() error {
vm.ModelTree.SortOrder = (vm.ModelTree.SortOrder + 1) % filetree.NumSortOrderConventions
return nil
}
func (vm *FileTreeViewModel) ConstrainLayout() {
if !vm.constrainedRealEstate {
logrus.Debugf("constraining filetree layout")
vm.constrainedRealEstate = true
@ -365,7 +372,7 @@ func (vm *FileTree) ConstrainLayout() {
}
}
func (vm *FileTree) ExpandLayout() {
func (vm *FileTreeViewModel) ExpandLayout() {
if vm.constrainedRealEstate {
logrus.Debugf("expanding filetree layout")
vm.ShowAttributes = vm.unconstrainedShowAttributes
@ -374,7 +381,7 @@ func (vm *FileTree) ExpandLayout() {
}
// ToggleCollapse will collapse/expand the selected FileNode.
func (vm *FileTree) ToggleAttributes() error {
func (vm *FileTreeViewModel) ToggleAttributes() error {
// ignore any attempt to show the attributes when the layout is constrained
if vm.constrainedRealEstate {
return nil
@ -384,12 +391,12 @@ func (vm *FileTree) ToggleAttributes() error {
}
// ToggleShowDiffType will show/hide the selected DiffType in the filetree pane.
func (vm *FileTree) ToggleShowDiffType(diffType filetree.DiffType) {
func (vm *FileTreeViewModel) ToggleShowDiffType(diffType filetree.DiffType) {
vm.HiddenDiffTypes[diffType] = !vm.HiddenDiffTypes[diffType]
}
// Update refreshes the state objects for future rendering.
func (vm *FileTree) Update(filterRegex *regexp.Regexp, width, height int) error {
func (vm *FileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height int) error {
vm.refWidth = width
vm.refHeight = height
@ -437,7 +444,7 @@ func (vm *FileTree) Update(filterRegex *regexp.Regexp, width, height int) error
}
// Render flushes the state objects (file tree) to the pane.
func (vm *FileTree) Render() error {
func (vm *FileTreeViewModel) Render() error {
treeString := vm.ViewTree.StringBetween(vm.bufferIndexLowerBound, vm.bufferIndexUpperBound(), vm.ShowAttributes)
lines := strings.Split(treeString, "\n")

View file

@ -73,7 +73,7 @@ func assertTestData(t *testing.T, actualBytes []byte) {
helperCheckDiff(t, expectedBytes, actualBytes)
}
func initializeTestViewModel(t *testing.T) *FileTree {
func initializeTestViewModel(t *testing.T) *FileTreeViewModel {
result := docker.TestAnalysisFromArchive(t, "../../../.data/test-docker-image.tar")
cache := filetree.NewComparer(result.RefTrees)
@ -98,7 +98,7 @@ func initializeTestViewModel(t *testing.T) *FileTree {
return vm
}
func runTestCase(t *testing.T, vm *FileTree, width, height int, filterRegex *regexp.Regexp) {
func runTestCase(t *testing.T, vm *FileTreeViewModel, width, height int, filterRegex *regexp.Regexp) {
err := vm.Update(filterRegex, width, height)
if err != nil {
t.Errorf("failed to update viewmodel: %v", err)