diff --git a/Makefile b/Makefile index ac291e5..18d3786 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ test-coverage: build ./.scripts/test-coverage.sh validate: - grep -R 'const allowTestDataCapture = false' runtime/ui/ + grep -R 'const allowTestDataCapture = false' runtime/ui/viewmodel go vet ./... @! gofmt -s -l . 2>&1 | grep -vE '^\.git/' | grep -vE '^\.cache/' golangci-lint run diff --git a/runtime/ui/controller.go b/runtime/ui/controller/controller.go similarity index 83% rename from runtime/ui/controller.go rename to runtime/ui/controller/controller.go index fb1e500..861b43f 100644 --- a/runtime/ui/controller.go +++ b/runtime/ui/controller/controller.go @@ -1,17 +1,13 @@ -package ui +package controller import ( "github.com/jroimartin/gocui" ) -type Renderable interface { - Update() error - Render() error -} - // Controller defines the a renderable terminal screen pane. type Controller interface { - Renderable + Update() error + Render() error Setup(*gocui.View, *gocui.View) error CursorDown() error CursorUp() error diff --git a/runtime/ui/controller/controller_collection.go b/runtime/ui/controller/controller_collection.go new file mode 100644 index 0000000..f0b7c7b --- /dev/null +++ b/runtime/ui/controller/controller_collection.go @@ -0,0 +1,166 @@ +package controller + +import ( + "errors" + "github.com/jroimartin/gocui" + "github.com/sirupsen/logrus" + "github.com/wagoodman/dive/dive/filetree" + "github.com/wagoodman/dive/dive/image" +) + +// var ccOnce sync.Once +var controllers *ControllerCollection + +type ControllerCollection struct { + gui *gocui.Gui + Tree *FileTreeController + Layer *LayerController + Status *StatusController + Filter *FilterController + Details *DetailsController + lookup map[string]Controller +} + +func NewControllerCollection(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeCache) (*ControllerCollection, error) { + var err error + + controllers = &ControllerCollection{ + gui: g, + } + controllers.lookup = make(map[string]Controller) + + controllers.Layer, err = NewLayerController("layers", g, analysis.Layers) + if err != nil { + return nil, err + } + controllers.lookup[controllers.Layer.name] = controllers.Layer + + treeStack, err := filetree.StackTreeRange(analysis.RefTrees, 0, 0) + if err != nil { + return nil, err + } + controllers.Tree, err = NewFileTreeController("filetree", g, treeStack, analysis.RefTrees, cache) + if err != nil { + return nil, err + } + controllers.lookup[controllers.Tree.name] = controllers.Tree + + controllers.Status = NewStatusController("status", g) + controllers.lookup[controllers.Status.name] = controllers.Status + + controllers.Filter = NewFilterController("filter", g) + controllers.lookup[controllers.Filter.name] = controllers.Filter + + controllers.Details = NewDetailsController("details", g, analysis.Efficiency, analysis.Inefficiencies) + controllers.lookup[controllers.Details.name] = controllers.Details + return controllers, nil +} + +func (c *ControllerCollection) UpdateAndRender() error { + err := c.Update() + if err != nil { + logrus.Debug("failed update: ", err) + return err + } + + err = c.Render() + if err != nil { + logrus.Debug("failed render: ", err) + return err + } + + return nil +} + +// Update refreshes the state objects for future rendering. +func (c *ControllerCollection) Update() error { + for _, controller := range c.lookup { + err := controller.Update() + if err != nil { + logrus.Debug("unable to update controller: ") + return err + } + } + return nil +} + +// Render flushes the state objects to the screen. +func (c *ControllerCollection) Render() error { + for _, controller := range c.lookup { + if controller.IsVisible() { + err := controller.Render() + if err != nil { + return err + } + } + } + return nil +} + +// ToggleView switches between the file view and the layer view and re-renders the screen. +func (c *ControllerCollection) ToggleView() (err error) { + v := c.gui.CurrentView() + if v == nil || v.Name() == c.Layer.Name() { + _, err = c.gui.SetCurrentView(c.Tree.Name()) + } else { + _, err = c.gui.SetCurrentView(c.Layer.Name()) + } + + if err != nil { + logrus.Error("unable to toggle view: ", err) + return err + } + + return c.UpdateAndRender() +} + +func (c *ControllerCollection) ToggleFilterView() error { + // delete all user input from the tree view + err := c.Filter.ToggleVisible() + if err != nil { + logrus.Error("unable to toggle filter visibility: ", err) + return err + } + + // we have just hidden the filter view, adjust focus to a valid (visible) view + if !c.Filter.IsVisible() { + err = c.ToggleView() + if err != nil { + logrus.Error("unable to toggle filter view (back): ", err) + return err + } + } + + return c.UpdateAndRender() +} + +// CursorDown moves the cursor down in the currently selected gocui pane, scrolling the screen as needed. +func (c *ControllerCollection) CursorDown(g *gocui.Gui, v *gocui.View) error { + return c.CursorStep(g, v, 1) +} + +// CursorUp moves the cursor up in the currently selected gocui pane, scrolling the screen as needed. +func (c *ControllerCollection) CursorUp(g *gocui.Gui, v *gocui.View) error { + return c.CursorStep(g, v, -1) +} + +// Moves the cursor the given step distance, setting the origin to the new cursor line +func (c *ControllerCollection) CursorStep(g *gocui.Gui, v *gocui.View, step int) error { + cx, cy := v.Cursor() + + // if there isn't a next line + line, err := v.Line(cy + step) + if err != nil { + return err + } + if len(line) == 0 { + return errors.New("unable to move the cursor, empty line") + } + if err := v.SetCursor(cx, cy+step); err != nil { + ox, oy := v.Origin() + if err := v.SetOrigin(ox, oy+step); err != nil { + return err + } + } + return nil +} diff --git a/runtime/ui/details_controller.go b/runtime/ui/controller/details_controller.go similarity index 84% rename from runtime/ui/details_controller.go rename to runtime/ui/controller/details_controller.go index ed01836..d3eccbc 100644 --- a/runtime/ui/details_controller.go +++ b/runtime/ui/controller/details_controller.go @@ -1,4 +1,4 @@ -package ui +package controller import ( "fmt" @@ -14,9 +14,9 @@ import ( "github.com/lunixbochs/vtclean" ) -// detailsController holds the UI objects and data models for populating the lower-left pane. Specifically the pane that +// 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 { +type DetailsController struct { name string gui *gocui.Gui view *gocui.View @@ -25,9 +25,9 @@ type detailsController struct { 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) +// 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 @@ -38,8 +38,12 @@ func newDetailsController(name string, gui *gocui.Gui, efficiency float64, ineff return controller } +func (controller *DetailsController) Name() string { + return controller.name +} + // 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 { +func (controller *DetailsController) Setup(v *gocui.View, header *gocui.View) error { // set controller options controller.view = v @@ -75,22 +79,22 @@ func (controller *detailsController) Setup(v *gocui.View, header *gocui.View) er } // IsVisible indicates if the details view pane is currently initialized. -func (controller *detailsController) IsVisible() bool { +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) +func (controller *DetailsController) CursorDown() error { + return controllers.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) +func (controller *DetailsController) CursorUp() error { + return controllers.CursorUp(controller.gui, controller.view) } // Update refreshes the state objects for future rendering. -func (controller *detailsController) Update() error { +func (controller *DetailsController) Update() error { return nil } @@ -99,7 +103,7 @@ func (controller *detailsController) Update() error { // 2. the image efficiency score // 3. the estimated wasted image space // 4. a list of inefficient file allocations -func (controller *detailsController) Render() error { +func (controller *DetailsController) Render() error { currentLayer := controllers.Layer.currentLayer() var wastedSpace int64 @@ -168,6 +172,6 @@ func (controller *detailsController) Render() error { } // KeyHelp indicates all the possible actions a user can take while the current pane is selected (currently does nothing). -func (controller *detailsController) KeyHelp() string { +func (controller *DetailsController) KeyHelp() string { return "TBD" } diff --git a/runtime/ui/filetree_controller.go b/runtime/ui/controller/filetree_controller.go similarity index 75% rename from runtime/ui/filetree_controller.go rename to runtime/ui/controller/filetree_controller.go index 250289a..5dc27db 100644 --- a/runtime/ui/filetree_controller.go +++ b/runtime/ui/controller/filetree_controller.go @@ -1,9 +1,10 @@ -package ui +package controller import ( "fmt" "github.com/wagoodman/dive/runtime/ui/format" "github.com/wagoodman/dive/runtime/ui/key" + "github.com/wagoodman/dive/runtime/ui/viewmodel" "regexp" "strings" @@ -19,36 +20,43 @@ const ( type CompareType int -// fileTreeController holds the UI objects and data models for populating the right pane. Specifically the pane that +// 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 { +type FileTreeController struct { name string gui *gocui.Gui view *gocui.View header *gocui.View - vm *fileTreeViewModel + vm *viewmodel.FileTreeViewModel helpKeys []*key.Binding } -// newFileTreeController creates a new view object attached the the global [gocui] screen object. -func newFileTreeController(name string, gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (controller *fileTreeController, err error) { - controller = new(fileTreeController) +// NewFileTreeController creates a new view object attached the the global [gocui] screen object. +func NewFileTreeController(name string, gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (controller *FileTreeController, err error) { + controller = new(FileTreeController) // populate main fields controller.name = name controller.gui = gui - controller.vm, err = newFileTreeViewModel(tree, refTrees, cache) + controller.vm, err = viewmodel.NewFileTreeViewModel(tree, refTrees, cache) if err != nil { return nil, err } - return controller, err } +func (controller *FileTreeController) Name() string { + return controller.name +} + +func (controller *FileTreeController) AreAttributesVisible() bool { + return controller.vm.ShowAttributes +} + // 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 { +func (controller *FileTreeController) Setup(v *gocui.View, header *gocui.View) error { // set controller options controller.view = v @@ -147,23 +155,23 @@ func (controller *fileTreeController) Setup(v *gocui.View, header *gocui.View) e } // IsVisible indicates if the file tree view pane is currently initialized -func (controller *fileTreeController) IsVisible() bool { +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() { +// 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() + 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) +// 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.ResetCursor() _ = controller.Update() return controller.Render() @@ -173,7 +181,7 @@ func (controller *fileTreeController) setTreeByLayer(bottomTreeStart, bottomTree // 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 { +func (controller *FileTreeController) CursorDown() error { if controller.vm.CursorDown() { return controller.Render() } @@ -184,7 +192,7 @@ func (controller *fileTreeController) CursorDown() error { // 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 { +func (controller *FileTreeController) CursorUp() error { if controller.vm.CursorUp() { return controller.Render() } @@ -192,7 +200,7 @@ func (controller *fileTreeController) CursorUp() error { } // CursorLeft moves the cursor up until we reach the Parent Node or top of the tree -func (controller *fileTreeController) CursorLeft() error { +func (controller *FileTreeController) CursorLeft() error { err := controller.vm.CursorLeft(filterRegex()) if err != nil { return err @@ -202,7 +210,7 @@ func (controller *fileTreeController) CursorLeft() error { } // CursorRight descends into directory expanding it if needed -func (controller *fileTreeController) CursorRight() error { +func (controller *FileTreeController) CursorRight() error { err := controller.vm.CursorRight(filterRegex()) if err != nil { return err @@ -212,7 +220,7 @@ func (controller *fileTreeController) CursorRight() error { } // PageDown moves to next page putting the cursor on top -func (controller *fileTreeController) PageDown() error { +func (controller *FileTreeController) PageDown() error { err := controller.vm.PageDown() if err != nil { return err @@ -221,7 +229,7 @@ func (controller *fileTreeController) PageDown() error { } // PageUp moves to previous page putting the cursor on top -func (controller *fileTreeController) PageUp() error { +func (controller *FileTreeController) PageUp() error { err := controller.vm.PageUp() if err != nil { return err @@ -230,13 +238,13 @@ func (controller *fileTreeController) PageUp() error { } // getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode. -// func (controller *fileTreeController) getAbsPositionNode() (node *filetree.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()) +// ToggleCollapse will collapse/expand the selected FileNode. +func (controller *FileTreeController) toggleCollapse() error { + err := controller.vm.ToggleCollapse(filterRegex()) if err != nil { return err } @@ -244,9 +252,9 @@ func (controller *fileTreeController) toggleCollapse() error { return controller.Render() } -// toggleCollapseAll will collapse/expand the all directories. -func (controller *fileTreeController) toggleCollapseAll() error { - err := controller.vm.toggleCollapseAll() +// ToggleCollapseAll will collapse/expand the all directories. +func (controller *FileTreeController) toggleCollapseAll() error { + err := controller.vm.ToggleCollapseAll() if err != nil { return err } @@ -257,21 +265,21 @@ func (controller *fileTreeController) toggleCollapseAll() error { return controller.Render() } -// toggleAttributes will show/hide file attributes -func (controller *fileTreeController) toggleAttributes() error { - err := controller.vm.toggleAttributes() +// ToggleAttributes will show/hide file attributes +func (controller *FileTreeController) toggleAttributes() error { + err := controller.vm.ToggleAttributes() if err != nil { return err } // we need to render the changes to the status pane as well (not just this contoller/view) - return UpdateAndRender() + return controllers.UpdateAndRender() } -// toggleShowDiffType will show/hide the selected DiffType in the filetree pane. -func (controller *fileTreeController) toggleShowDiffType(diffType filetree.DiffType) error { - controller.vm.toggleShowDiffType(diffType) +// ToggleShowDiffType will show/hide the selected DiffType in the filetree pane. +func (controller *FileTreeController) toggleShowDiffType(diffType filetree.DiffType) error { + controller.vm.ToggleShowDiffType(diffType) // we need to render the changes to the status pane as well (not just this contoller/view) - return UpdateAndRender() + return controllers.UpdateAndRender() } // filterRegex will return a regular expression object to match the user's filter input. @@ -292,8 +300,8 @@ func filterRegex() *regexp.Regexp { 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 { +// 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() @@ -302,7 +310,7 @@ func (controller *fileTreeController) onLayoutChange(resized bool) error { } // Update refreshes the state objects for future rendering. -func (controller *fileTreeController) Update() error { +func (controller *FileTreeController) Update() error { var width, height int if controller.view != nil { @@ -316,7 +324,7 @@ func (controller *fileTreeController) Update() error { } // Render flushes the state objects (file tree) to the pane. -func (controller *fileTreeController) Render() error { +func (controller *FileTreeController) Render() error { title := "Current Layer Contents" if controllers.Layer.CompareMode == CompareAll { title = "Aggregated Layer Contents" @@ -344,7 +352,7 @@ func (controller *fileTreeController) Render() error { if err != nil { return err } - _, err = fmt.Fprint(controller.view, controller.vm.mainBuf.String()) + _, err = fmt.Fprint(controller.view, controller.vm.Buffer.String()) return err }) @@ -352,7 +360,7 @@ func (controller *fileTreeController) Render() error { } // KeyHelp indicates all the possible actions a user can take while the current pane is selected. -func (controller *fileTreeController) KeyHelp() string { +func (controller *FileTreeController) KeyHelp() string { var help string for _, binding := range controller.helpKeys { help += binding.RenderKeyHelp() diff --git a/runtime/ui/filter_controller.go b/runtime/ui/controller/filter_controller.go similarity index 61% rename from runtime/ui/filter_controller.go rename to runtime/ui/controller/filter_controller.go index e01cead..2ff718d 100644 --- a/runtime/ui/filter_controller.go +++ b/runtime/ui/controller/filter_controller.go @@ -1,4 +1,4 @@ -package ui +package controller import ( "fmt" @@ -7,9 +7,9 @@ import ( "github.com/wagoodman/dive/runtime/ui/format" ) -// filterController holds the UI objects and data models for populating the bottom row. Specifically the pane that +// FilterController holds the UI objects and data models for populating the bottom row. Specifically the pane that // allows the user to filter the file tree by path. -type filterController struct { +type FilterController struct { name string gui *gocui.Gui view *gocui.View @@ -19,9 +19,9 @@ type filterController struct { hidden bool } -// newFilterController creates a new view object attached the the global [gocui] screen object. -func newFilterController(name string, gui *gocui.Gui) (controller *filterController) { - controller = new(filterController) +// NewFilterController creates a new view object attached the the global [gocui] screen object. +func NewFilterController(name string, gui *gocui.Gui) (controller *FilterController) { + controller = new(FilterController) // populate main fields controller.name = name @@ -32,8 +32,12 @@ func newFilterController(name string, gui *gocui.Gui) (controller *filterControl return controller } +func (controller *FilterController) Name() string { + return controller.name +} + // Setup initializes the UI concerns within the context of a global [gocui] view object. -func (controller *filterController) Setup(v *gocui.View, header *gocui.View) error { +func (controller *FilterController) Setup(v *gocui.View, header *gocui.View) error { // set controller options controller.view = v @@ -52,8 +56,36 @@ func (controller *filterController) Setup(v *gocui.View, header *gocui.View) err return controller.Render() } +// ToggleFilterView shows/hides the file tree filter pane. +func (controller *FilterController) ToggleVisible() error { + // delete all user input from the tree view + controller.view.Clear() + + // toggle hiding + controller.hidden = !controller.hidden + + if !controller.hidden { + _, err := controller.gui.SetCurrentView(controller.name) + if err != nil { + logrus.Error("unable to toggle filter view: ", err) + return err + } + return controllers.UpdateAndRender() + } + + // reset the cursor for the next time it is visible + // Note: there is a subtle gocui behavior here where this cannot be called when the view + // is newly visible. Is this a problem with dive or gocui? + return controller.view.SetCursor(0, 0) +} + +// todo: remove the need for this +func (controller *FilterController) HeaderStr() string { + return controller.headerStr +} + // IsVisible indicates if the filter view pane is currently initialized -func (controller *filterController) IsVisible() bool { +func (controller *FilterController) IsVisible() bool { if controller == nil { return false } @@ -61,17 +93,17 @@ func (controller *filterController) IsVisible() bool { } // CursorDown moves the cursor down in the filter pane (currently indicates nothing). -func (controller *filterController) CursorDown() error { +func (controller *FilterController) CursorDown() error { return nil } // CursorUp moves the cursor up in the filter pane (currently indicates nothing). -func (controller *filterController) CursorUp() error { +func (controller *FilterController) CursorUp() error { return nil } // Edit intercepts the key press events in the filer view to update the file view in real time. -func (controller *filterController) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { +func (controller *FilterController) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { if !controller.IsVisible() { return } @@ -94,12 +126,12 @@ func (controller *filterController) Edit(v *gocui.View, key gocui.Key, ch rune, } // Update refreshes the state objects for future rendering (currently does nothing). -func (controller *filterController) Update() error { +func (controller *FilterController) Update() error { return nil } // Render flushes the state objects to the screen. Currently this is the users path filter input. -func (controller *filterController) Render() error { +func (controller *FilterController) Render() error { controller.gui.Update(func(g *gocui.Gui) error { _, err := fmt.Fprintln(controller.header, format.Header(controller.headerStr)) if err != nil { @@ -111,6 +143,6 @@ func (controller *filterController) Render() error { } // KeyHelp indicates all the possible actions a user can take while the current pane is selected. -func (controller *filterController) KeyHelp() string { +func (controller *FilterController) KeyHelp() string { return format.StatusControlNormal("▏Type to filter the file tree ") } diff --git a/runtime/ui/layer_controller.go b/runtime/ui/controller/layer_controller.go similarity index 81% rename from runtime/ui/layer_controller.go rename to runtime/ui/controller/layer_controller.go index a14897f..a0bf0c0 100644 --- a/runtime/ui/layer_controller.go +++ b/runtime/ui/controller/layer_controller.go @@ -1,4 +1,4 @@ -package ui +package controller import ( "fmt" @@ -13,9 +13,9 @@ import ( "github.com/spf13/viper" ) -// layerController holds the UI objects and data models for populating the lower-left pane. Specifically the pane that +// LayerController holds the UI objects and data models for populating the lower-left pane. Specifically the pane that // shows the image layers and layer selector. -type layerController struct { +type LayerController struct { name string gui *gocui.Gui view *gocui.View @@ -29,9 +29,9 @@ type layerController struct { helpKeys []*key.Binding } -// newLayerController creates a new view object attached the the global [gocui] screen object. -func newLayerController(name string, gui *gocui.Gui, layers []*image.Layer) (controller *layerController, err error) { - controller = new(layerController) +// NewLayerController creates a new view object attached the the global [gocui] screen object. +func NewLayerController(name string, gui *gocui.Gui, layers []*image.Layer) (controller *LayerController, err error) { + controller = new(LayerController) // populate main fields controller.name = name @@ -50,8 +50,12 @@ func newLayerController(name string, gui *gocui.Gui, layers []*image.Layer) (con return controller, err } +func (controller *LayerController) Name() string { + return controller.name +} + // Setup initializes the UI concerns within the context of a global [gocui] view object. -func (controller *layerController) Setup(v *gocui.View, header *gocui.View) error { +func (controller *LayerController) Setup(v *gocui.View, header *gocui.View) error { // set controller options controller.view = v @@ -64,17 +68,16 @@ func (controller *layerController) Setup(v *gocui.View, header *gocui.View) erro controller.header.Wrap = false controller.header.Frame = false - var infos = []key.BindingInfo{ { ConfigKeys: []string{"keybinding.compare-layer"}, - OnAction: func() error { return controller.setCompareMode(CompareLayer) }, + OnAction: func() error { return controller.setCompareMode(CompareLayer) }, IsSelected: func() bool { return controller.CompareMode == CompareLayer }, Display: "Show layer changes", }, { ConfigKeys: []string{"keybinding.compare-all"}, - OnAction: func() error { return controller.setCompareMode(CompareAll) }, + OnAction: func() error { return controller.setCompareMode(CompareAll) }, IsSelected: func() bool { return controller.CompareMode == CompareAll }, Display: "Show aggregated changes", }, @@ -114,23 +117,22 @@ func (controller *layerController) Setup(v *gocui.View, header *gocui.View) erro } controller.helpKeys = helpKeys - return controller.Render() } // height obtains the height of the current pane (taking into account the lost space due to the header). -func (controller *layerController) height() uint { +func (controller *LayerController) height() uint { _, height := controller.view.Size() return uint(height - 1) } // IsVisible indicates if the layer view pane is currently initialized. -func (controller *layerController) IsVisible() bool { +func (controller *LayerController) IsVisible() bool { return controller != nil } // PageDown moves to next page putting the cursor on top -func (controller *layerController) PageDown() error { +func (controller *LayerController) PageDown() error { step := int(controller.height()) + 1 targetLayerIndex := controller.LayerIndex + step @@ -139,7 +141,7 @@ func (controller *layerController) PageDown() error { } if step > 0 { - err := CursorStep(controller.gui, controller.view, step) + err := controllers.CursorStep(controller.gui, controller.view, step) if err == nil { return controller.SetCursor(controller.LayerIndex + step) } @@ -148,7 +150,7 @@ func (controller *layerController) PageDown() error { } // PageUp moves to previous page putting the cursor on top -func (controller *layerController) PageUp() error { +func (controller *LayerController) PageUp() error { step := int(controller.height()) + 1 targetLayerIndex := controller.LayerIndex - step @@ -157,7 +159,7 @@ func (controller *layerController) PageUp() error { } if step > 0 { - err := CursorStep(controller.gui, controller.view, -step) + err := controllers.CursorStep(controller.gui, controller.view, -step) if err == nil { return controller.SetCursor(controller.LayerIndex - step) } @@ -166,9 +168,9 @@ func (controller *layerController) PageUp() error { } // CursorDown moves the cursor down in the layer pane (selecting a higher layer). -func (controller *layerController) CursorDown() error { +func (controller *LayerController) CursorDown() error { if controller.LayerIndex < len(controller.Layers) { - err := CursorDown(controller.gui, controller.view) + err := controllers.CursorDown(controller.gui, controller.view) if err == nil { return controller.SetCursor(controller.LayerIndex + 1) } @@ -177,9 +179,9 @@ func (controller *layerController) CursorDown() error { } // CursorUp moves the cursor up in the layer pane (selecting a lower layer). -func (controller *layerController) CursorUp() error { +func (controller *LayerController) CursorUp() error { if controller.LayerIndex > 0 { - err := CursorUp(controller.gui, controller.view) + err := controllers.CursorUp(controller.gui, controller.view) if err == nil { return controller.SetCursor(controller.LayerIndex - 1) } @@ -188,7 +190,7 @@ func (controller *layerController) CursorUp() error { } // SetCursor resets the cursor and orients the file tree view based on the given layer index. -func (controller *layerController) SetCursor(layer int) error { +func (controller *LayerController) SetCursor(layer int) error { controller.LayerIndex = layer err := controllers.Tree.setTreeByLayer(controller.getCompareIndexes()) if err != nil { @@ -201,14 +203,14 @@ func (controller *layerController) SetCursor(layer int) error { } // currentLayer returns the Layer object currently selected. -func (controller *layerController) currentLayer() *image.Layer { +func (controller *LayerController) currentLayer() *image.Layer { return controller.Layers[controller.LayerIndex] } // setCompareMode switches the layer comparison between a single-layer comparison to an aggregated comparison. -func (controller *layerController) setCompareMode(compareMode CompareType) error { +func (controller *LayerController) setCompareMode(compareMode CompareType) error { controller.CompareMode = compareMode - err := UpdateAndRender() + err := controllers.UpdateAndRender() if err != nil { logrus.Errorf("unable to set compare mode: %+v", err) return err @@ -217,7 +219,7 @@ func (controller *layerController) setCompareMode(compareMode CompareType) error } // getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode) -func (controller *layerController) getCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) { +func (controller *LayerController) getCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) { bottomTreeStart = controller.CompareStartIndex topTreeStop = controller.LayerIndex @@ -236,7 +238,7 @@ func (controller *layerController) getCompareIndexes() (bottomTreeStart, bottomT } // renderCompareBar returns the formatted string for the given layer. -func (controller *layerController) renderCompareBar(layerIdx int) string { +func (controller *LayerController) renderCompareBar(layerIdx int) string { bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := controller.getCompareIndexes() result := " " @@ -251,7 +253,7 @@ func (controller *layerController) renderCompareBar(layerIdx int) string { } // Update refreshes the state objects for future rendering (currently does nothing). -func (controller *layerController) Update() error { +func (controller *LayerController) Update() error { controller.ImageSize = 0 for idx := 0; idx < len(controller.Layers); idx++ { controller.ImageSize += controller.Layers[idx].Size @@ -262,7 +264,7 @@ func (controller *layerController) Update() error { // Render flushes the state objects to the screen. The layers pane reports: // 1. the layers of the image + metadata // 2. the current selected image -func (controller *layerController) Render() error { +func (controller *LayerController) Render() error { // indicate when selected title := "Layers" @@ -306,7 +308,7 @@ func (controller *layerController) Render() error { } // KeyHelp indicates all the possible actions a user can take while the current pane is selected. -func (controller *layerController) KeyHelp() string { +func (controller *LayerController) KeyHelp() string { var help string for _, binding := range controller.helpKeys { help += binding.RenderKeyHelp() diff --git a/runtime/ui/status_controller.go b/runtime/ui/controller/status_controller.go similarity index 62% rename from runtime/ui/status_controller.go rename to runtime/ui/controller/status_controller.go index 31f7ce9..7de9a30 100644 --- a/runtime/ui/status_controller.go +++ b/runtime/ui/controller/status_controller.go @@ -1,4 +1,4 @@ -package ui +package controller import ( "fmt" @@ -10,9 +10,9 @@ import ( "github.com/jroimartin/gocui" ) -// statusController holds the UI objects and data models for populating the bottom-most pane. Specifically the panel +// StatusController holds the UI objects and data models for populating the bottom-most pane. Specifically the panel // shows the user a set of possible actions to take in the window and currently selected pane. -type statusController struct { +type StatusController struct { name string gui *gocui.Gui view *gocui.View @@ -20,9 +20,9 @@ type statusController struct { helpKeys []*key.Binding } -// newStatusController creates a new view object attached the the global [gocui] screen object. -func newStatusController(name string, gui *gocui.Gui) (controller *statusController) { - controller = new(statusController) +// NewStatusController creates a new view object attached the the global [gocui] screen object. +func NewStatusController(name string, gui *gocui.Gui) (controller *StatusController) { + controller = new(StatusController) // populate main fields controller.name = name @@ -32,14 +32,16 @@ func newStatusController(name string, gui *gocui.Gui) (controller *statusControl return controller } -func (controller *statusController) AddHelpKeys(keys ...*key.Binding) { - for _, k := range keys { - controller.helpKeys = append(controller.helpKeys, k) - } +func (controller *StatusController) Name() string { + return controller.name +} + +func (controller *StatusController) AddHelpKeys(keys ...*key.Binding) { + controller.helpKeys = append(controller.helpKeys, keys...) } // Setup initializes the UI concerns within the context of a global [gocui] view object. -func (controller *statusController) Setup(v *gocui.View, header *gocui.View) error { +func (controller *StatusController) Setup(v *gocui.View, header *gocui.View) error { // set controller options controller.view = v @@ -49,30 +51,30 @@ func (controller *statusController) Setup(v *gocui.View, header *gocui.View) err } // IsVisible indicates if the status view pane is currently initialized. -func (controller *statusController) IsVisible() bool { +func (controller *StatusController) IsVisible() bool { return controller != nil } // CursorDown moves the cursor down in the details pane (currently indicates nothing). -func (controller *statusController) CursorDown() error { +func (controller *StatusController) CursorDown() error { return nil } // CursorUp moves the cursor up in the details pane (currently indicates nothing). -func (controller *statusController) CursorUp() error { +func (controller *StatusController) CursorUp() error { return nil } // Update refreshes the state objects for future rendering (currently does nothing). -func (controller *statusController) Update() error { +func (controller *StatusController) Update() error { return nil } // Render flushes the state objects to the screen. -func (controller *statusController) Render() error { +func (controller *StatusController) Render() error { controller.gui.Update(func(g *gocui.Gui) error { controller.view.Clear() - _, err := fmt.Fprintln(controller.view, controller.KeyHelp()+controllers.lookup[controller.gui.CurrentView().Name()].KeyHelp()+format.StatusNormal("▏"+strings.Repeat(" ", 1000))) + _, err := fmt.Fprintln(controller.view, controller.KeyHelp()+format.StatusNormal("▏"+strings.Repeat(" ", 1000))) if err != nil { logrus.Debug("unable to write to buffer: ", err) } @@ -83,7 +85,7 @@ func (controller *statusController) Render() error { } // KeyHelp indicates all the possible global actions a user can take when any pane is selected. -func (controller *statusController) KeyHelp() string { +func (controller *StatusController) KeyHelp() string { var help string for _, binding := range controller.helpKeys { help += binding.RenderKeyHelp() diff --git a/runtime/ui/controller_collection.go b/runtime/ui/controller_collection.go deleted file mode 100644 index 47be653..0000000 --- a/runtime/ui/controller_collection.go +++ /dev/null @@ -1,52 +0,0 @@ -package ui - -import ( - "github.com/jroimartin/gocui" - "github.com/wagoodman/dive/dive/filetree" - "github.com/wagoodman/dive/dive/image" -) - -// var ccOnce sync.Once -var controllers *controllerCollection - -type controllerCollection struct { - Tree *fileTreeController - Layer *layerController - Status *statusController - Filter *filterController - Details *detailsController - lookup map[string]Controller -} - -func newControllerCollection(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeCache) (*controllerCollection, error) { - var err error - - controllers = &controllerCollection{} - controllers.lookup = make(map[string]Controller) - - controllers.Layer, err = newLayerController("layers", g, analysis.Layers) - if err != nil { - return nil, err - } - controllers.lookup[controllers.Layer.name] = controllers.Layer - - treeStack, err := filetree.StackTreeRange(analysis.RefTrees, 0, 0) - if err != nil { - return nil, err - } - controllers.Tree, err = newFileTreeController("filetree", g, treeStack, analysis.RefTrees, cache) - if err != nil { - return nil, err - } - controllers.lookup[controllers.Tree.name] = controllers.Tree - - controllers.Status = newStatusController("status", g) - controllers.lookup[controllers.Status.name] = controllers.Status - - controllers.Filter = newFilterController("filter", g) - controllers.lookup[controllers.Filter.name] = controllers.Filter - - controllers.Details = newDetailsController("details", g, analysis.Efficiency, analysis.Inefficiencies) - controllers.lookup[controllers.Details.name] = controllers.Details - return controllers, nil -} diff --git a/runtime/ui/key/binding.go b/runtime/ui/key/binding.go index d7a75af..777c047 100644 --- a/runtime/ui/key/binding.go +++ b/runtime/ui/key/binding.go @@ -97,7 +97,6 @@ func (binding *Binding) RegisterSelectionFn(selectedFn func() bool) { } func (binding *Binding) onAction(*gocui.Gui, *gocui.View) error { - logrus.Debugf("keybinding invoked: %+v", binding) if binding.actionFn == nil { return fmt.Errorf("no action configured for '%+v'", binding) } diff --git a/runtime/ui/layout_manager.go b/runtime/ui/layout_manager.go index 4672ac5..561f683 100644 --- a/runtime/ui/layout_manager.go +++ b/runtime/ui/layout_manager.go @@ -1,11 +1,169 @@ package ui +import ( + "github.com/jroimartin/gocui" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/wagoodman/dive/runtime/ui/controller" +) + type layoutManager struct { fileTreeSplitRatio float64 + controllers *controller.ControllerCollection } -func newLayoutManager(fileTreeSplitRatio float64) *layoutManager { +func newLayoutManager(c *controller.ControllerCollection) *layoutManager { + + fileTreeSplitRatio := viper.GetFloat64("filetree.pane-width") + if fileTreeSplitRatio >= 1 || fileTreeSplitRatio <= 0 { + logrus.Errorf("invalid config value: 'filetree.pane-width' should be 0 < value < 1, given '%v'", fileTreeSplitRatio) + fileTreeSplitRatio = 0.5 + } + return &layoutManager{ fileTreeSplitRatio: fileTreeSplitRatio, + controllers: c, } } + +// IsNewView determines if a view has already been created based on the set of errors given (a bit hokie) +func IsNewView(errs ...error) bool { + for _, err := range errs { + if err == nil { + return false + } + if err != gocui.ErrUnknownView { + return false + } + } + return true +} + +// layout defines the definition of the window pane size and placement relations to one another. This +// is invoked at application start and whenever the screen dimensions change. +func (lm *layoutManager) layout(g *gocui.Gui) error { + // TODO: this logic should be refactored into an abstraction that takes care of the math for us + + maxX, maxY := g.Size() + var resized bool + if maxX != lastX { + resized = true + } + if maxY != lastY { + resized = true + } + lastX, lastY = maxX, maxY + + splitCols := int(float64(maxX) * (1.0 - lm.fileTreeSplitRatio)) + debugWidth := 0 + if debug { + debugWidth = maxX / 4 + } + debugCols := maxX - debugWidth + bottomRows := 1 + headerRows := 2 + + filterBarHeight := 1 + statusBarHeight := 1 + + statusBarIndex := 1 + filterBarIndex := 2 + + layersHeight := len(lm.controllers.Layer.Layers) + headerRows + 1 // layers + header + base image layer row + maxLayerHeight := int(0.75 * float64(maxY)) + if layersHeight > maxLayerHeight { + layersHeight = maxLayerHeight + } + + var view, header *gocui.View + var viewErr, headerErr, err error + + if !lm.controllers.Filter.IsVisible() { + bottomRows-- + filterBarHeight = 0 + } + + // Debug pane + if debug { + if _, err := g.SetView("debug", debugCols, -1, maxX, maxY-bottomRows); err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + } + + // Layers + view, viewErr = g.SetView(lm.controllers.Layer.Name(), -1, -1+headerRows, splitCols, layersHeight) + header, headerErr = g.SetView(lm.controllers.Layer.Name()+"header", -1, -1, splitCols, headerRows) + if IsNewView(viewErr, headerErr) { + err = lm.controllers.Layer.Setup(view, header) + if err != nil { + logrus.Error("unable to setup layer controller", err) + return err + } + + if _, err = g.SetCurrentView(lm.controllers.Layer.Name()); err != nil { + logrus.Error("unable to set view to layer", err) + return err + } + // since we are selecting the view, we should rerender to indicate it is selected + err = lm.controllers.Layer.Render() + if err != nil { + logrus.Error("unable to render layer view", err) + return err + } + } + + // Details + view, viewErr = g.SetView(lm.controllers.Details.Name(), -1, -1+layersHeight+headerRows, splitCols, maxY-bottomRows) + header, headerErr = g.SetView(lm.controllers.Details.Name()+"header", -1, -1+layersHeight, splitCols, layersHeight+headerRows) + if IsNewView(viewErr, headerErr) { + err = lm.controllers.Details.Setup(view, header) + if err != nil { + return err + } + } + + // Filetree + offset := 0 + if !lm.controllers.Tree.AreAttributesVisible() { + offset = 1 + } + view, viewErr = g.SetView(lm.controllers.Tree.Name(), splitCols, -1+headerRows-offset, debugCols, maxY-bottomRows) + header, headerErr = g.SetView(lm.controllers.Tree.Name()+"header", splitCols, -1, debugCols, headerRows-offset) + if IsNewView(viewErr, headerErr) { + err = lm.controllers.Tree.Setup(view, header) + if err != nil { + logrus.Error("unable to setup tree controller", err) + return err + } + } + err = lm.controllers.Tree.OnLayoutChange(resized) + if err != nil { + logrus.Error("unable to setup layer controller onLayoutChange", err) + return err + } + + // Status Bar + view, viewErr = g.SetView(lm.controllers.Status.Name(), -1, maxY-statusBarHeight-statusBarIndex, maxX, maxY-(statusBarIndex-1)) + if IsNewView(viewErr, headerErr) { + err = lm.controllers.Status.Setup(view, nil) + if err != nil { + logrus.Error("unable to setup status controller", err) + return err + } + } + + // Filter Bar + view, viewErr = g.SetView(lm.controllers.Filter.Name(), len(lm.controllers.Filter.HeaderStr())-1, maxY-filterBarHeight-filterBarIndex, maxX, maxY-(filterBarIndex-1)) + header, headerErr = g.SetView(lm.controllers.Filter.Name()+"header", -1, maxY-filterBarHeight-filterBarIndex, len(lm.controllers.Filter.HeaderStr()), maxY-(filterBarIndex-1)) + if IsNewView(viewErr, headerErr) { + err = lm.controllers.Filter.Setup(view, header) + if err != nil { + logrus.Error("unable to setup filter controller", err) + return err + } + } + + return nil +} diff --git a/runtime/ui/ui.go b/runtime/ui/ui.go index 3859c35..b4fa834 100644 --- a/runtime/ui/ui.go +++ b/runtime/ui/ui.go @@ -1,14 +1,13 @@ package ui import ( - "errors" "github.com/wagoodman/dive/dive/image" + "github.com/wagoodman/dive/runtime/ui/controller" "github.com/wagoodman/dive/runtime/ui/key" "sync" "github.com/jroimartin/gocui" "github.com/sirupsen/logrus" - "github.com/spf13/viper" "github.com/wagoodman/dive/dive/filetree" ) @@ -16,8 +15,9 @@ const debug = false // type global type app struct { - gui *gocui.Gui - controllers *controllerCollection + gui *gocui.Gui + controllers *controller.ControllerCollection + layout *layoutManager } var ( @@ -28,17 +28,19 @@ var ( func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeCache) (*app, error) { var err error once.Do(func() { - var theControls *controllerCollection + var theControls *controller.ControllerCollection var globalHelpKeys []*key.Binding - theControls, err = newControllerCollection(gui, analysis, cache) + theControls, err = controller.NewControllerCollection(gui, analysis, cache) if err != nil { return } + lm := newLayoutManager(theControls) + gui.Cursor = false //g.Mouse = true - gui.SetManagerFunc(layout) + gui.SetManagerFunc(lm.layout) // var profileObj = profile.Start(profile.CPUProfile, profile.ProfilePath("."), profile.NoShutdownHook) // @@ -46,27 +48,27 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeC // profileObj.Stop() // } - appSingleton = &app{ gui: gui, controllers: theControls, + layout: lm, } var infos = []key.BindingInfo{ { ConfigKeys: []string{"keybinding.quit"}, - OnAction: quit, + OnAction: appSingleton.quit, Display: "Quit", }, { ConfigKeys: []string{"keybinding.toggle-view"}, - OnAction: appSingleton.toggleView, + OnAction: theControls.ToggleView, Display: "Switch view", }, { ConfigKeys: []string{"keybinding.filter-files"}, - OnAction: appSingleton.toggleFilterView, - IsSelected: controllers.Filter.IsVisible, + OnAction: theControls.ToggleFilterView, + IsSelected: theControls.Filter.IsVisible, Display: "Filter", }, } @@ -78,14 +80,12 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeC theControls.Status.AddHelpKeys(globalHelpKeys...) - // perform the first update and render now that all resources have been loaded - err = UpdateAndRender() + err = theControls.UpdateAndRender() if err != nil { return } - }) return appSingleton, err @@ -109,103 +109,8 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeC var lastX, lastY int -func UpdateAndRender() error { - err := Update() - if err != nil { - logrus.Debug("failed update: ", err) - return err - } - - err = Render() - if err != nil { - logrus.Debug("failed render: ", err) - return err - } - - return nil -} - -// toggleView switches between the file view and the layer view and re-renders the screen. -func (ui *app) toggleView() (err error) { - v := ui.gui.CurrentView() - if v == nil || v.Name() == controllers.Layer.name { - _, err = ui.gui.SetCurrentView(controllers.Tree.name) - } else { - _, err = ui.gui.SetCurrentView(controllers.Layer.name) - } - - if err != nil { - logrus.Error("unable to toggle view: ", err) - return err - } - - return UpdateAndRender() -} - -// toggleFilterView shows/hides the file tree filter pane. -func (ui *app) toggleFilterView() error { - // delete all user input from the tree view - controllers.Filter.view.Clear() - - // toggle hiding - controllers.Filter.hidden = !controllers.Filter.hidden - - if !controllers.Filter.hidden { - _, err := ui.gui.SetCurrentView(controllers.Filter.name) - if err != nil { - logrus.Error("unable to toggle filter view: ", err) - return err - } - return UpdateAndRender() - } - - err := ui.toggleView() - if err != nil { - logrus.Error("unable to toggle filter view (back): ", err) - return err - } - - err = controllers.Filter.view.SetCursor(0, 0) - if err != nil { - return err - } - - return nil -} - -// CursorDown moves the cursor down in the currently selected gocui pane, scrolling the screen as needed. -func CursorDown(g *gocui.Gui, v *gocui.View) error { - return CursorStep(g, v, 1) -} - -// CursorUp moves the cursor up in the currently selected gocui pane, scrolling the screen as needed. -func CursorUp(g *gocui.Gui, v *gocui.View) error { - return CursorStep(g, v, -1) -} - -// Moves the cursor the given step distance, setting the origin to the new cursor line -func CursorStep(g *gocui.Gui, v *gocui.View, step int) error { - cx, cy := v.Cursor() - - // if there isn't a next line - line, err := v.Line(cy + step) - if err != nil { - return err - } - if len(line) == 0 { - return errors.New("unable to move the cursor, empty line") - } - if err := v.SetCursor(cx, cy+step); err != nil { - ox, oy := v.Origin() - if err := v.SetOrigin(ox, oy+step); err != nil { - return err - } - } - return nil -} - // quit is the gocui callback invoked when the user hits Ctrl+C -func quit() error { +func (ui *app) quit() error { // profileObj.Stop() // onExit() @@ -213,177 +118,6 @@ func quit() error { return gocui.ErrQuit } -// isNewView determines if a view has already been created based on the set of errors given (a bit hokie) -func isNewView(errs ...error) bool { - for _, err := range errs { - if err == nil { - return false - } - if err != gocui.ErrUnknownView { - return false - } - } - return true -} - -// layout defines the definition of the window pane size and placement relations to one another. This -// is invoked at application start and whenever the screen dimensions change. -func layout(g *gocui.Gui) error { - // TODO: this logic should be refactored into an abstraction that takes care of the math for us - - maxX, maxY := g.Size() - var resized bool - if maxX != lastX { - resized = true - } - if maxY != lastY { - resized = true - } - lastX, lastY = maxX, maxY - fileTreeSplitRatio := viper.GetFloat64("filetree.pane-width") - if fileTreeSplitRatio >= 1 || fileTreeSplitRatio <= 0 { - logrus.Errorf("invalid config value: 'filetree.pane-width' should be 0 < value < 1, given '%v'", fileTreeSplitRatio) - fileTreeSplitRatio = 0.5 - } - splitCols := int(float64(maxX) * (1.0 - fileTreeSplitRatio)) - debugWidth := 0 - if debug { - debugWidth = maxX / 4 - } - debugCols := maxX - debugWidth - bottomRows := 1 - headerRows := 2 - - filterBarHeight := 1 - statusBarHeight := 1 - - statusBarIndex := 1 - filterBarIndex := 2 - - layersHeight := len(controllers.Layer.Layers) + headerRows + 1 // layers + header + base image layer row - maxLayerHeight := int(0.75 * float64(maxY)) - if layersHeight > maxLayerHeight { - layersHeight = maxLayerHeight - } - - var view, header *gocui.View - var viewErr, headerErr, err error - - if controllers.Filter.hidden { - bottomRows-- - filterBarHeight = 0 - } - - // Debug pane - if debug { - if _, err := g.SetView("debug", debugCols, -1, maxX, maxY-bottomRows); err != nil { - if err != gocui.ErrUnknownView { - return err - } - } - } - - // Layers - view, viewErr = g.SetView(controllers.Layer.name, -1, -1+headerRows, splitCols, layersHeight) - header, headerErr = g.SetView(controllers.Layer.name+"header", -1, -1, splitCols, headerRows) - if isNewView(viewErr, headerErr) { - err = controllers.Layer.Setup(view, header) - if err != nil { - logrus.Error("unable to setup layer controller", err) - return err - } - - if _, err = g.SetCurrentView(controllers.Layer.name); err != nil { - logrus.Error("unable to set view to layer", err) - return err - } - // since we are selecting the view, we should rerender to indicate it is selected - err = controllers.Layer.Render() - if err != nil { - logrus.Error("unable to render layer view", err) - return err - } - } - - // Details - view, viewErr = g.SetView(controllers.Details.name, -1, -1+layersHeight+headerRows, splitCols, maxY-bottomRows) - header, headerErr = g.SetView(controllers.Details.name+"header", -1, -1+layersHeight, splitCols, layersHeight+headerRows) - if isNewView(viewErr, headerErr) { - err = controllers.Details.Setup(view, header) - if err != nil { - return err - } - } - - // Filetree - offset := 0 - if !controllers.Tree.vm.ShowAttributes { - offset = 1 - } - view, viewErr = g.SetView(controllers.Tree.name, splitCols, -1+headerRows-offset, debugCols, maxY-bottomRows) - header, headerErr = g.SetView(controllers.Tree.name+"header", splitCols, -1, debugCols, headerRows-offset) - if isNewView(viewErr, headerErr) { - err = controllers.Tree.Setup(view, header) - if err != nil { - logrus.Error("unable to setup tree controller", err) - return err - } - } - err = controllers.Tree.onLayoutChange(resized) - if err != nil { - logrus.Error("unable to setup layer controller onLayoutChange", err) - return err - } - - // Status Bar - view, viewErr = g.SetView(controllers.Status.name, -1, maxY-statusBarHeight-statusBarIndex, maxX, maxY-(statusBarIndex-1)) - if isNewView(viewErr, headerErr) { - err = controllers.Status.Setup(view, nil) - if err != nil { - logrus.Error("unable to setup status controller", err) - return err - } - } - - // Filter Bar - view, viewErr = g.SetView(controllers.Filter.name, len(controllers.Filter.headerStr)-1, maxY-filterBarHeight-filterBarIndex, maxX, maxY-(filterBarIndex-1)) - header, headerErr = g.SetView(controllers.Filter.name+"header", -1, maxY-filterBarHeight-filterBarIndex, len(controllers.Filter.headerStr), maxY-(filterBarIndex-1)) - if isNewView(viewErr, headerErr) { - err = controllers.Filter.Setup(view, header) - if err != nil { - logrus.Error("unable to setup filter controller", err) - return err - } - } - - return nil -} - -// Update refreshes the state objects for future rendering. -func Update() error { - for _, controller := range controllers.lookup { - err := controller.Update() - if err != nil { - logrus.Debug("unable to update controller: ") - return err - } - } - return nil -} - -// Render flushes the state objects to the screen. -func Render() error { - for _, controller := range controllers.lookup { - if controller.IsVisible() { - err := controller.Render() - if err != nil { - return err - } - } - } - return nil -} - // Run is the UI entrypoint. func Run(analysis *image.AnalysisResult, cache filetree.TreeCache) error { var err error diff --git a/runtime/ui/filetree_viewmodel.go b/runtime/ui/viewmodel/filetree_viewmodel.go similarity index 83% rename from runtime/ui/filetree_viewmodel.go rename to runtime/ui/viewmodel/filetree_viewmodel.go index 5a640e8..cf467e6 100644 --- a/runtime/ui/filetree_viewmodel.go +++ b/runtime/ui/viewmodel/filetree_viewmodel.go @@ -1,4 +1,4 @@ -package ui +package viewmodel import ( "bytes" @@ -13,9 +13,9 @@ import ( "github.com/wagoodman/dive/dive/filetree" ) -// fileTreeViewModel holds the UI objects and data models for populating the right pane. Specifically the pane that +// FileTreeViewModel holds the UI objects and data models for populating the right pane. Specifically the pane that // shows selected layer or aggregate file ASCII tree. -type fileTreeViewModel struct { +type FileTreeViewModel struct { ModelTree *filetree.FileTree ViewTree *filetree.FileTree RefTrees []*filetree.FileTree @@ -31,12 +31,12 @@ type fileTreeViewModel struct { refHeight int refWidth int - mainBuf bytes.Buffer + Buffer bytes.Buffer } -// newFileTreeViewModel creates a new view object attached the the global [gocui] screen object. -func newFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (treeViewModel *fileTreeViewModel, err error) { - treeViewModel = new(fileTreeViewModel) +// NewFileTreeViewModel creates a new view object attached the the global [gocui] screen object. +func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (treeViewModel *FileTreeViewModel, err error) { + treeViewModel = new(FileTreeViewModel) // populate main fields treeViewModel.ShowAttributes = viper.GetBool("filetree.show-attributes") @@ -66,13 +66,13 @@ func newFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree } // Setup initializes the UI concerns within the context of a global [gocui] view object. -func (vm *fileTreeViewModel) Setup(lowerBound, height int) { +func (vm *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 { +func (vm *FileTreeViewModel) height() int { if vm.ShowAttributes { return vm.refHeight - 1 } @@ -80,24 +80,24 @@ func (vm *fileTreeViewModel) height() int { } // bufferIndexUpperBound returns the current upper bounds for the view -func (vm *fileTreeViewModel) 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 *fileTreeViewModel) 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 *fileTreeViewModel) resetCursor() { +// 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 { +// 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) } @@ -126,7 +126,7 @@ func (vm *fileTreeViewModel) setTreeByLayer(bottomTreeStart, bottomTreeStop, top } // doCursorUp performs the internal view's buffer adjustments on cursor up. Note: this is independent of the gocui buffer. -func (vm *fileTreeViewModel) CursorUp() bool { +func (vm *FileTreeViewModel) CursorUp() bool { if vm.TreeIndex <= 0 { return false } @@ -141,7 +141,7 @@ func (vm *fileTreeViewModel) CursorUp() bool { } // doCursorDown performs the internal view's buffer adjustments on cursor down. Note: this is independent of the gocui buffer. -func (vm *fileTreeViewModel) CursorDown() bool { +func (vm *FileTreeViewModel) CursorDown() bool { if vm.TreeIndex >= vm.ModelTree.VisibleSize() { return false } @@ -157,7 +157,7 @@ func (vm *fileTreeViewModel) CursorDown() bool { } // CursorLeft moves the cursor up until we reach the Parent Node or top of the tree -func (vm *fileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error { +func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error { var visitor func(*filetree.FileNode) error var evaluator func(*filetree.FileNode) bool var dfsCounter, newIndex int @@ -208,7 +208,7 @@ func (vm *fileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error { } // CursorRight descends into directory expanding it if needed -func (vm *fileTreeViewModel) CursorRight(filterRegex *regexp.Regexp) error { +func (vm *FileTreeViewModel) CursorRight(filterRegex *regexp.Regexp) error { node := vm.getAbsPositionNode(filterRegex) if node == nil { return nil @@ -240,7 +240,7 @@ func (vm *fileTreeViewModel) CursorRight(filterRegex *regexp.Regexp) error { } // PageDown moves to next page putting the cursor on top -func (vm *fileTreeViewModel) PageDown() error { +func (vm *FileTreeViewModel) PageDown() error { nextBufferIndexLowerBound := vm.bufferIndexLowerBound + vm.height() nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height() @@ -266,7 +266,7 @@ func (vm *fileTreeViewModel) PageDown() error { } // PageUp moves to previous page putting the cursor on top -func (vm *fileTreeViewModel) PageUp() error { +func (vm *FileTreeViewModel) PageUp() error { nextBufferIndexLowerBound := vm.bufferIndexLowerBound - vm.height() nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height() @@ -291,7 +291,7 @@ func (vm *fileTreeViewModel) PageUp() error { } // getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode. -func (vm *fileTreeViewModel) getAbsPositionNode(filterRegex *regexp.Regexp) (node *filetree.FileNode) { +func (vm *FileTreeViewModel) getAbsPositionNode(filterRegex *regexp.Regexp) (node *filetree.FileNode) { var visitor func(*filetree.FileNode) error var evaluator func(*filetree.FileNode) bool var dfsCounter int @@ -321,8 +321,8 @@ func (vm *fileTreeViewModel) getAbsPositionNode(filterRegex *regexp.Regexp) (nod return node } -// toggleCollapse will collapse/expand the selected FileNode. -func (vm *fileTreeViewModel) toggleCollapse(filterRegex *regexp.Regexp) error { +// ToggleCollapse will collapse/expand the selected FileNode. +func (vm *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 @@ -330,8 +330,8 @@ func (vm *fileTreeViewModel) toggleCollapse(filterRegex *regexp.Regexp) error { return nil } -// toggleCollapseAll will collapse/expand the all directories. -func (vm *fileTreeViewModel) toggleCollapseAll() error { +// ToggleCollapseAll will collapse/expand the all directories. +func (vm *FileTreeViewModel) ToggleCollapseAll() error { vm.CollapseAll = !vm.CollapseAll visitor := func(curNode *filetree.FileNode) error { @@ -345,25 +345,25 @@ func (vm *fileTreeViewModel) toggleCollapseAll() error { err := vm.ModelTree.VisitDepthChildFirst(visitor, evaluator) if err != nil { - logrus.Errorf("unable to propagate tree on toggleCollapseAll: %+v", err) + logrus.Errorf("unable to propagate tree on ToggleCollapseAll: %+v", err) } return nil } -// toggleCollapse will collapse/expand the selected FileNode. -func (vm *fileTreeViewModel) toggleAttributes() error { +// ToggleCollapse will collapse/expand the selected FileNode. +func (vm *FileTreeViewModel) ToggleAttributes() error { vm.ShowAttributes = !vm.ShowAttributes return nil } -// toggleShowDiffType will show/hide the selected DiffType in the filetree pane. -func (vm *fileTreeViewModel) toggleShowDiffType(diffType filetree.DiffType) { +// ToggleShowDiffType will show/hide the selected DiffType in the filetree pane. +func (vm *FileTreeViewModel) ToggleShowDiffType(diffType filetree.DiffType) { vm.HiddenDiffTypes[diffType] = !vm.HiddenDiffTypes[diffType] } // Update refreshes the state objects for future rendering. -func (vm *fileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height int) error { +func (vm *FileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height int) error { vm.refWidth = width vm.refHeight = height @@ -411,21 +411,21 @@ func (vm *fileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height in } // Render flushes the state objects (file tree) to the pane. -func (vm *fileTreeViewModel) Render() error { +func (vm *FileTreeViewModel) Render() error { treeString := vm.ViewTree.StringBetween(vm.bufferIndexLowerBound, vm.bufferIndexUpperBound(), vm.ShowAttributes) lines := strings.Split(treeString, "\n") // update the contents - vm.mainBuf.Reset() + vm.Buffer.Reset() for idx, line := range lines { if idx == vm.bufferIndex { - _, err := fmt.Fprintln(&vm.mainBuf, format.Selected(vtclean.Clean(line, false))) + _, err := fmt.Fprintln(&vm.Buffer, format.Selected(vtclean.Clean(line, false))) if err != nil { logrus.Debug("unable to write to buffer: ", err) return err } } else { - _, err := fmt.Fprintln(&vm.mainBuf, line) + _, err := fmt.Fprintln(&vm.Buffer, line) if err != nil { logrus.Debug("unable to write to buffer: ", err) return err diff --git a/runtime/ui/filetree_viewmodel_test.go b/runtime/ui/viewmodel/filetree_viewmodel_test.go similarity index 86% rename from runtime/ui/filetree_viewmodel_test.go rename to runtime/ui/viewmodel/filetree_viewmodel_test.go index fde2587..3d92a67 100644 --- a/runtime/ui/filetree_viewmodel_test.go +++ b/runtime/ui/viewmodel/filetree_viewmodel_test.go @@ -1,4 +1,4 @@ -package ui +package viewmodel import ( "bytes" @@ -73,8 +73,8 @@ func assertTestData(t *testing.T, actualBytes []byte) { helperCheckDiff(t, expectedBytes, actualBytes) } -func initializeTestViewModel(t *testing.T) *fileTreeViewModel { - result := docker.TestAnalysisFromArchive(t, "../../.data/test-docker-image.tar") +func initializeTestViewModel(t *testing.T) *FileTreeViewModel { + result := docker.TestAnalysisFromArchive(t, "../../../.data/test-docker-image.tar") cache := filetree.NewFileTreeCache(result.RefTrees) err := cache.Build() @@ -88,14 +88,14 @@ func initializeTestViewModel(t *testing.T) *fileTreeViewModel { if err != nil { t.Fatalf("%s: unable to stack trees: %v", t.Name(), err) } - vm, err := newFileTreeViewModel(treeStack, result.RefTrees, cache) + vm, err := NewFileTreeViewModel(treeStack, result.RefTrees, cache) if err != nil { t.Fatalf("%s: unable to create tree ViewModel: %+v", t.Name(), err) } return vm } -func runTestCase(t *testing.T, vm *fileTreeViewModel, 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) @@ -106,7 +106,7 @@ func runTestCase(t *testing.T, vm *fileTreeViewModel, width, height int, filterR t.Errorf("failed to render viewmodel: %v", err) } - assertTestData(t, vm.mainBuf.Bytes()) + assertTestData(t, vm.Buffer.Bytes()) } func checkError(t *testing.T, err error, message string) { @@ -153,7 +153,7 @@ func TestFileTreeDirCollapse(t *testing.T) { vm.ShowAttributes = true // collapse /bin - err := vm.toggleCollapse(nil) + err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") moved := vm.CursorDown() @@ -167,7 +167,7 @@ func TestFileTreeDirCollapse(t *testing.T) { } // collapse /etc - err = vm.toggleCollapse(nil) + err = vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /etc") runTestCase(t, vm, width, height, nil) @@ -180,7 +180,7 @@ func TestFileTreeDirCollapseAll(t *testing.T) { vm.Setup(0, height) vm.ShowAttributes = true - err := vm.toggleCollapseAll() + err := vm.ToggleCollapseAll() checkError(t, err, "unable to collapse all dir") runTestCase(t, vm, width, height, nil) @@ -194,13 +194,13 @@ func TestFileTreeSelectLayer(t *testing.T) { vm.ShowAttributes = true // collapse /bin - err := vm.toggleCollapse(nil) + err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the next layer, compareMode = layer - err = vm.setTreeByLayer(0, 0, 1, 1) + err = vm.SetTreeByLayer(0, 0, 1, 1) if err != nil { - t.Errorf("unable to setTreeByLayer: %v", err) + t.Errorf("unable to SetTreeByLayer: %v", err) } runTestCase(t, vm, width, height, nil) } @@ -213,12 +213,12 @@ func TestFileShowAggregateChanges(t *testing.T) { vm.ShowAttributes = true // collapse /bin - err := vm.toggleCollapse(nil) + err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the next layer, compareMode = layer - err = vm.setTreeByLayer(0, 0, 1, 13) - checkError(t, err, "unable to setTreeByLayer") + err = vm.SetTreeByLayer(0, 0, 1, 13) + checkError(t, err, "unable to SetTreeByLayer") runTestCase(t, vm, width, height, nil) } @@ -275,7 +275,7 @@ func TestFileTreeDirCursorRight(t *testing.T) { vm.ShowAttributes = true // collapse /bin - err := vm.toggleCollapse(nil) + err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") moved := vm.CursorDown() @@ -289,7 +289,7 @@ func TestFileTreeDirCursorRight(t *testing.T) { } // collapse /etc - err = vm.toggleCollapse(nil) + err = vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /etc") // expand /etc @@ -322,23 +322,23 @@ func TestFileTreeHideAddedRemovedModified(t *testing.T) { vm.ShowAttributes = true // collapse /bin - err := vm.toggleCollapse(nil) + err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the 7th layer, compareMode = layer - err = vm.setTreeByLayer(0, 0, 1, 7) + err = vm.SetTreeByLayer(0, 0, 1, 7) if err != nil { - t.Errorf("unable to setTreeByLayer: %v", err) + t.Errorf("unable to SetTreeByLayer: %v", err) } // hide added files - vm.toggleShowDiffType(filetree.Added) + vm.ToggleShowDiffType(filetree.Added) // hide modified files - vm.toggleShowDiffType(filetree.Modified) + vm.ToggleShowDiffType(filetree.Modified) // hide removed files - vm.toggleShowDiffType(filetree.Removed) + vm.ToggleShowDiffType(filetree.Removed) runTestCase(t, vm, width, height, nil) } @@ -351,17 +351,17 @@ func TestFileTreeHideUnmodified(t *testing.T) { vm.ShowAttributes = true // collapse /bin - err := vm.toggleCollapse(nil) + err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the 7th layer, compareMode = layer - err = vm.setTreeByLayer(0, 0, 1, 7) + err = vm.SetTreeByLayer(0, 0, 1, 7) if err != nil { - t.Errorf("unable to setTreeByLayer: %v", err) + t.Errorf("unable to SetTreeByLayer: %v", err) } // hide unmodified files - vm.toggleShowDiffType(filetree.Unmodified) + vm.ToggleShowDiffType(filetree.Unmodified) runTestCase(t, vm, width, height, nil) } @@ -374,17 +374,17 @@ func TestFileTreeHideTypeWithFilter(t *testing.T) { vm.ShowAttributes = true // collapse /bin - err := vm.toggleCollapse(nil) + err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the 7th layer, compareMode = layer - err = vm.setTreeByLayer(0, 0, 1, 7) + err = vm.SetTreeByLayer(0, 0, 1, 7) if err != nil { - t.Errorf("unable to setTreeByLayer: %v", err) + t.Errorf("unable to SetTreeByLayer: %v", err) } // hide added files - vm.toggleShowDiffType(filetree.Added) + vm.ToggleShowDiffType(filetree.Added) regex, err := regexp.Compile("saved") if err != nil { diff --git a/runtime/ui/testdata/TestFileShowAggregateChanges.txt b/runtime/ui/viewmodel/testdata/TestFileShowAggregateChanges.txt similarity index 100% rename from runtime/ui/testdata/TestFileShowAggregateChanges.txt rename to runtime/ui/viewmodel/testdata/TestFileShowAggregateChanges.txt diff --git a/runtime/ui/testdata/TestFileTreeDirCollapse.txt b/runtime/ui/viewmodel/testdata/TestFileTreeDirCollapse.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeDirCollapse.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeDirCollapse.txt diff --git a/runtime/ui/testdata/TestFileTreeDirCollapseAll.txt b/runtime/ui/viewmodel/testdata/TestFileTreeDirCollapseAll.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeDirCollapseAll.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeDirCollapseAll.txt diff --git a/runtime/ui/testdata/TestFileTreeDirCursorRight.txt b/runtime/ui/viewmodel/testdata/TestFileTreeDirCursorRight.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeDirCursorRight.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeDirCursorRight.txt diff --git a/runtime/ui/testdata/TestFileTreeFilterTree.txt b/runtime/ui/viewmodel/testdata/TestFileTreeFilterTree.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeFilterTree.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeFilterTree.txt diff --git a/runtime/ui/testdata/TestFileTreeGoCase.txt b/runtime/ui/viewmodel/testdata/TestFileTreeGoCase.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeGoCase.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeGoCase.txt diff --git a/runtime/ui/testdata/TestFileTreeHideAddedRemovedModified.txt b/runtime/ui/viewmodel/testdata/TestFileTreeHideAddedRemovedModified.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeHideAddedRemovedModified.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeHideAddedRemovedModified.txt diff --git a/runtime/ui/testdata/TestFileTreeHideTypeWithFilter.txt b/runtime/ui/viewmodel/testdata/TestFileTreeHideTypeWithFilter.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeHideTypeWithFilter.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeHideTypeWithFilter.txt diff --git a/runtime/ui/testdata/TestFileTreeHideUnmodified.txt b/runtime/ui/viewmodel/testdata/TestFileTreeHideUnmodified.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeHideUnmodified.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeHideUnmodified.txt diff --git a/runtime/ui/testdata/TestFileTreeNoAttributes.txt b/runtime/ui/viewmodel/testdata/TestFileTreeNoAttributes.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeNoAttributes.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeNoAttributes.txt diff --git a/runtime/ui/testdata/TestFileTreePageDown.txt b/runtime/ui/viewmodel/testdata/TestFileTreePageDown.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreePageDown.txt rename to runtime/ui/viewmodel/testdata/TestFileTreePageDown.txt diff --git a/runtime/ui/testdata/TestFileTreePageUp.txt b/runtime/ui/viewmodel/testdata/TestFileTreePageUp.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreePageUp.txt rename to runtime/ui/viewmodel/testdata/TestFileTreePageUp.txt diff --git a/runtime/ui/testdata/TestFileTreeRestrictedHeight.txt b/runtime/ui/viewmodel/testdata/TestFileTreeRestrictedHeight.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeRestrictedHeight.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeRestrictedHeight.txt diff --git a/runtime/ui/testdata/TestFileTreeSelectLayer.txt b/runtime/ui/viewmodel/testdata/TestFileTreeSelectLayer.txt similarity index 100% rename from runtime/ui/testdata/TestFileTreeSelectLayer.txt rename to runtime/ui/viewmodel/testdata/TestFileTreeSelectLayer.txt