From e46b9080061a8a28e6e42a5f6a0625f2db692ca6 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Wed, 19 Oct 2022 21:09:38 -0700 Subject: [PATCH] support filtered list panel without dependency on gui package --- pkg/gui/containers_panel.go | 6 +- pkg/gui/filtered_list.go | 12 ++ pkg/gui/gui.go | 23 ++++ pkg/gui/images_panel.go | 18 ++- pkg/gui/keybindings.go | 2 +- pkg/gui/list_panel.go | 228 +++++++++++++++++++++++++++++++++++- pkg/gui/project_panel.go | 4 +- pkg/gui/view_helpers.go | 34 +++++- pkg/gui/volumes_panel.go | 4 +- 9 files changed, 314 insertions(+), 17 deletions(-) diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index 2e245ab2..2b66fc36 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -105,7 +105,7 @@ func (gui *Gui) renderContainerEnv(container *commands.Container) error { mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel return gui.T.NewTask(func(stop chan struct{}) { - _ = gui.renderString(gui.g, "main", gui.containerEnv(container)) + _ = gui.renderStringMain(gui.containerEnv(container)) }) } @@ -139,7 +139,7 @@ func (gui *Gui) containerEnv(container *commands.Container) string { func (gui *Gui) renderContainerConfig(container *commands.Container) error { if !container.DetailsLoaded() { return gui.T.NewTask(func(stop chan struct{}) { - _ = gui.renderString(gui.g, "main", gui.Tr.WaitingForContainerInfo) + _ = gui.renderStringMain(gui.Tr.WaitingForContainerInfo) }) } @@ -189,7 +189,7 @@ func (gui *Gui) renderContainerConfig(container *commands.Container) error { output += fmt.Sprintf("\nFull details:\n\n%s", string(data)) return gui.T.NewTask(func(stop chan struct{}) { - _ = gui.renderString(gui.g, "main", output) + _ = gui.renderStringMain(output) }) } diff --git a/pkg/gui/filtered_list.go b/pkg/gui/filtered_list.go index b79c2725..925983fd 100644 --- a/pkg/gui/filtered_list.go +++ b/pkg/gui/filtered_list.go @@ -56,6 +56,18 @@ func (self *FilteredList[T]) Get(index int) T { return self.allItems[self.indices[index]] } +func (self *FilteredList[T]) TryGet(index int) (T, bool) { + self.mutex.RLock() + defer self.mutex.RUnlock() + + if index < 0 || index >= len(self.indices) { + var zero T + return zero, false + } + + return self.allItems[self.indices[index]], true +} + // returns the length of the filtered list func (self *FilteredList[T]) Len() int { self.mutex.RLock() diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index c8d7d78f..dc4df9ef 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -74,6 +74,12 @@ type Gui struct { PauseBackgroundThreads bool Mutexes + + Panels Panels +} + +type Panels struct { + Images *SideListPanel[*commands.Image] } type Mutexes struct { @@ -210,6 +216,11 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand CyclableViews: cyclableViews, } + // TODO: see if we can avoid the circular dependency + gui.Panels = Panels{ + Images: gui.getImagePanel(), + } + gui.GenerateSentinelErrors() return gui, nil @@ -460,9 +471,21 @@ func (gui *Gui) shouldRefresh(key string) bool { return true } +func (gui *Gui) ShouldRefresh(key string) bool { + return gui.shouldRefresh(key) +} + func (gui *Gui) initiallyFocusedViewName() string { if gui.DockerCommand.InDockerComposeProject { return "services" } return "containers" } + +func (gui *Gui) IgnoreStrings() []string { + return gui.Config.UserConfig.Ignore +} + +func (gui *Gui) Update(f func() error) { + gui.g.Update(func(*gocui.Gui) error { return f() }) +} diff --git a/pkg/gui/images_panel.go b/pkg/gui/images_panel.go index 733227d7..f83b1a8c 100644 --- a/pkg/gui/images_panel.go +++ b/pkg/gui/images_panel.go @@ -43,17 +43,17 @@ func (gui *Gui) handleImagesClick(g *gocui.Gui, v *gocui.View) error { } func (gui *Gui) handleImageSelect(g *gocui.Gui, v *gocui.View) error { - Image, err := gui.getSelectedImage() + image, err := gui.getSelectedImage() if err != nil { if err != gui.Errors.ErrNoImages { return err } - return gui.renderString(g, "main", gui.Tr.NoImages) + return gui.renderStringMain(gui.Tr.NoImages) } gui.focusY(gui.State.Panels.Images.SelectedLine, gui.State.Lists.Images.Len(), v) - key := "images-" + Image.ID + "-" + gui.getImageContexts()[gui.State.Panels.Images.ContextIndex] + key := "images-" + image.ID + "-" + gui.getImageContexts()[gui.State.Panels.Images.ContextIndex] if !gui.shouldRefresh(key) { return nil } @@ -64,7 +64,7 @@ func (gui *Gui) handleImageSelect(g *gocui.Gui, v *gocui.View) error { switch gui.getImageContexts()[gui.State.Panels.Images.ContextIndex] { case "config": - if err := gui.renderImageConfig(mainView, Image); err != nil { + if err := gui.renderImageConfig(image); err != nil { return err } default: @@ -74,7 +74,7 @@ func (gui *Gui) handleImageSelect(g *gocui.Gui, v *gocui.View) error { return nil } -func (gui *Gui) renderImageConfig(mainView *gocui.View, image *commands.Image) error { +func (gui *Gui) renderImageConfig(image *commands.Image) error { return gui.T.NewTask(func(stop chan struct{}) { padding := 10 output := "" @@ -91,10 +91,11 @@ func (gui *Gui) renderImageConfig(mainView *gocui.View, image *commands.Image) e output += "\n\n" + history + mainView := gui.Views.Main mainView.Autoscroll = false mainView.Wrap = false // don't care what your config is this page is ugly without wrapping - _ = gui.renderString(gui.g, "main", output) + _ = gui.renderStringMain(output) }) } @@ -182,6 +183,11 @@ func (gui *Gui) filterString(view *gocui.View) string { return gui.State.Searching.searchString } +// TODO: merge into the above +func (gui *Gui) FilterString(view *gocui.View) string { + return gui.filterString(view) +} + func (gui *Gui) handleImagesNextLine(g *gocui.Gui, v *gocui.View) error { if gui.popupPanelFocused() || gui.g.CurrentView() != v { return nil diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 18d9ec8d..76d66fa1 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -586,7 +586,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { "menu": {onKeyUpPress: gui.handleMenuPrevLine, onKeyDownPress: gui.handleMenuNextLine, onClick: gui.handleMenuClick}, "services": {onKeyUpPress: gui.handleServicesPrevLine, onKeyDownPress: gui.handleServicesNextLine, onClick: gui.handleServicesClick}, "containers": {onKeyUpPress: gui.handleContainersPrevLine, onKeyDownPress: gui.handleContainersNextLine, onClick: gui.handleContainersClick}, - "images": {onKeyUpPress: gui.handleImagesPrevLine, onKeyDownPress: gui.handleImagesNextLine, onClick: gui.handleImagesClick}, + "images": {onKeyUpPress: wrappedHandler(gui.Panels.Images.OnPrevLine), onKeyDownPress: wrappedHandler(gui.Panels.Images.OnNextLine), onClick: wrappedHandler(gui.Panels.Images.OnClick)}, "volumes": {onKeyUpPress: gui.handleVolumesPrevLine, onKeyDownPress: gui.handleVolumesNextLine, onClick: gui.handleVolumesClick}, "main": {onKeyUpPress: gui.scrollUpMain, onKeyDownPress: gui.scrollDownMain, onClick: gui.handleMainClick}, } diff --git a/pkg/gui/list_panel.go b/pkg/gui/list_panel.go index d1db53eb..64335a97 100644 --- a/pkg/gui/list_panel.go +++ b/pkg/gui/list_panel.go @@ -1,15 +1,21 @@ package gui import ( + "fmt" + "strings" + + "github.com/go-errors/errors" "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/samber/lo" ) type ListPanel[T comparable] struct { toColumns func(T) []string selectedIdx int - list FilteredList[T] + list *FilteredList[T] view *gocui.View } @@ -42,4 +48,224 @@ type SideListPanel[T comparable] struct { ListPanel[T] contextIdx int + + noItemsMessge string + + gui IGui + + contexts []ContextConfig[T] + + // returns strings that can be filtered on + getSearchStrings func(item T) []string + + sort func(a, b T) bool +} + +type ContextConfig[T any] struct { + key string + title string + render func(item T) error +} + +type IGui interface { + HandleClick(v *gocui.View, itemCount int, selectedLine *int, handleSelect func() error) error + RenderStringMain(message string) error + FocusY(selectedLine int, itemCount int, view *gocui.View) + ShouldRefresh(key string) bool + GetMainView() *gocui.View + PopupPanelFocused() bool + // TODO: replace with IsCurrentView() bool + CurrentView() *gocui.View + FilterString(view *gocui.View) string + IgnoreStrings() []string + Update(func() error) +} + +func (gui *Gui) intoInterface() IGui { + return gui +} + +func (gui *Gui) getImagePanel() *SideListPanel[*commands.Image] { + noneLabel := "" + + return &SideListPanel[*commands.Image]{ + contextKeyPrefix: "images", + ListPanel: ListPanel[*commands.Image]{ + toColumns: func(image *commands.Image) []string { + return []string{ + image.Name, + image.Tag, + utils.FormatDecimalBytes(int(image.Image.Size)), + } + }, + list: NewFilteredList[*commands.Image](), + view: gui.Views.Images, + }, + contextIdx: -1, // TODO: see if this should be 0 + noItemsMessge: gui.Tr.NoImages, + gui: gui.intoInterface(), + contexts: []ContextConfig[*commands.Image]{ + { + key: "config", + title: gui.Tr.ConfigTitle, + render: func(image *commands.Image) error { + return gui.renderImageConfig(image) + }, + }, + }, + getSearchStrings: func(image *commands.Image) []string { + return []string{image.Name, image.Tag} + }, + sort: func(a *commands.Image, b *commands.Image) bool { + if a.Name == noneLabel && b.Name != noneLabel { + return false + } + + if a.Name != noneLabel && b.Name == noneLabel { + return true + } + + return a.Name < b.Name + }, + } +} + +func (self *SideListPanel[T]) OnClick() error { + itemCount := self.list.Len() + handleSelect := self.HandleSelect + selectedLine := &self.selectedIdx + + return self.gui.HandleClick(self.view, itemCount, selectedLine, handleSelect) +} + +func (self *SideListPanel[T]) HandleSelect() error { + item, err := self.GetSelectedItem() + if err != nil { + if err.Error() != self.noItemsMessge { + return err + } + + return self.gui.RenderStringMain(self.noItemsMessge) + } + + self.gui.FocusY(self.selectedIdx, self.list.Len(), self.view) + + key := self.contextKeyPrefix + "-" + self.contexts[self.contextIdx].key + if !self.gui.ShouldRefresh(key) { + return nil + } + + mainView := self.gui.GetMainView() + mainView.Tabs = self.GetContextTitles() + mainView.TabIndex = self.contextIdx + + // now I have an item. What do I do with it? + return self.contexts[self.contextIdx].render(item) +} + +func (self *SideListPanel[T]) GetContextTitles() []string { + return lo.Map(self.contexts, func(context ContextConfig[T], _ int) string { + return context.title + }) +} + +func (self *SideListPanel[T]) GetSelectedItem() (T, error) { + var zero T + + if self.selectedIdx == -1 { + return zero, errors.New(self.noItemsMessge) + } + + item, ok := self.list.TryGet(self.selectedIdx) + if !ok { + // could probably have a better error here + return zero, errors.New(self.noItemsMessge) + } + + return item, nil +} + +func (self *SideListPanel[T]) OnNextLine() error { + if self.ignoreKeypress() { + return nil + } + + self.SelectNextLine() + + return self.HandleSelect() +} + +func (self *SideListPanel[T]) OnPrevLine() error { + if self.ignoreKeypress() { + return nil + } + + self.SelectPrevLine() + + return self.HandleSelect() +} + +func (self *SideListPanel[T]) ignoreKeypress() bool { + return self.gui.PopupPanelFocused() || self.gui.CurrentView() != self.view +} + +func (self *SideListPanel[T]) OnNextContext() error { + self.contextIdx = (self.contextIdx + 1) % len(self.contexts) + + return self.HandleSelect() +} + +func (self *SideListPanel[T]) OnPrevContext() error { + self.contextIdx = (self.contextIdx - 1 + len(self.contexts)) % len(self.contexts) + + return self.HandleSelect() +} + +func (self *SideListPanel[T]) RerenderList() error { + filterString := self.gui.FilterString(self.view) + + self.list.Filter(func(item T, index int) bool { + if lo.SomeBy(self.gui.IgnoreStrings(), func(ignore string) bool { + return lo.SomeBy(self.getSearchStrings(item), func(searchString string) bool { + return strings.Contains(searchString, ignore) + }) + }) { + return false + } + + if filterString != "" { + return lo.SomeBy(self.getSearchStrings(item), func(searchString string) bool { + return strings.Contains(searchString, filterString) + }) + } + + return true + }) + + self.list.Sort(self.sort) + + // TODO: use clamp? + if self.list.Len() > 0 && self.selectedIdx == -1 { + self.selectedIdx = 0 + } + if self.list.Len()-1 < self.selectedIdx { + self.selectedIdx = self.list.Len() - 1 + } + + self.gui.Update(func() error { + self.view.Clear() + isFocused := self.gui.CurrentView() == self.view + list, err := utils.RenderList(self.list.GetItems(), utils.IsFocused(isFocused)) + if err != nil { + return err + } + fmt.Fprint(self.view, list) + + if self.view == self.gui.CurrentView() { + return self.HandleSelect() + } + return nil + }) + + return nil } diff --git a/pkg/gui/project_panel.go b/pkg/gui/project_panel.go index 6edee4fa..3a2922ab 100644 --- a/pkg/gui/project_panel.go +++ b/pkg/gui/project_panel.go @@ -123,7 +123,7 @@ func (gui *Gui) renderCredits() error { configBuf.String(), }, "\n\n") - _ = gui.renderString(gui.g, "main", dashboardString) + _ = gui.renderStringMain(dashboardString) }) } @@ -166,7 +166,7 @@ func (gui *Gui) renderDockerComposeConfig() error { mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel config := gui.DockerCommand.DockerComposeConfig() - _ = gui.renderString(gui.g, "main", config) + _ = gui.renderStringMain(config) }) } diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index fc344aff..76bc732e 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -229,6 +229,11 @@ func (gui *Gui) focusY(selectedY int, lineCount int, v *gocui.View) { gui.focusPoint(0, selectedY, lineCount, v) } +// TODO: combine with above +func (gui *Gui) FocusY(selectedY int, lineCount int, v *gocui.View) { + gui.focusY(selectedY, lineCount, v) +} + func (gui *Gui) cleanString(s string) string { output := string(bom.Clean([]byte(s))) return utils.NormalizeLinefeeds(output) @@ -262,6 +267,11 @@ func (gui *Gui) renderStringMain(s string) error { return gui.renderString(gui.g, "main", s) } +// TODO: merge with above +func (gui *Gui) RenderStringMain(s string) error { + return gui.renderStringMain(s) +} + // reRenderString sets the main view's content, without changing its origin func (gui *Gui) reRenderStringMain(s string) { gui.reRenderString("main", s) @@ -317,8 +327,11 @@ func (gui *Gui) getVolumesView() *gocui.View { } func (gui *Gui) getMainView() *gocui.View { - v, _ := gui.g.View("main") - return v + return gui.Views.Main +} + +func (gui *Gui) GetMainView() *gocui.View { + return gui.getMainView() } func (gui *Gui) trimmedContent(v *gocui.View) string { @@ -390,6 +403,11 @@ func (gui *Gui) popupPanelFocused() bool { return gui.isPopupPanel(gui.currentViewName()) } +// TODO: merge into above +func (gui *Gui) PopupPanelFocused() bool { + return gui.popupPanelFocused() +} + func (gui *Gui) clearMainView() { mainView := gui.getMainView() mainView.Clear() @@ -424,6 +442,14 @@ func (gui *Gui) handleClick(v *gocui.View, itemCount int, selectedLine *int, han return handleSelect(gui.g, v) } +// TODO: combine with above +func (gui *Gui) HandleClick(v *gocui.View, itemCount int, selectedLine *int, handleSelect func() error) error { + wrappedHandleSelect := func(g *gocui.Gui, v *gocui.View) error { + return handleSelect() + } + return gui.handleClick(v, itemCount, selectedLine, wrappedHandleSelect) +} + func (gui *Gui) nextScreenMode() error { if gui.currentViewName() == "main" { gui.State.ScreenMode = prevIntInCycle([]WindowMaximisation{SCREEN_NORMAL, SCREEN_HALF, SCREEN_FULL}, gui.State.ScreenMode) @@ -471,3 +497,7 @@ func prevIntInCycle(sl []WindowMaximisation, current WindowMaximisation) WindowM } return sl[len(sl)-1] } + +func (gui *Gui) CurrentView() *gocui.View { + return gui.g.CurrentView() +} diff --git a/pkg/gui/volumes_panel.go b/pkg/gui/volumes_panel.go index 7d2cf936..405eb57b 100644 --- a/pkg/gui/volumes_panel.go +++ b/pkg/gui/volumes_panel.go @@ -44,7 +44,7 @@ func (gui *Gui) handleVolumeSelect(g *gocui.Gui, v *gocui.View) error { if err != gui.Errors.ErrNoVolumes { return err } - return gui.renderString(g, "main", gui.Tr.NoVolumes) + return gui.renderStringMain(gui.Tr.NoVolumes) } gui.focusY(gui.State.Panels.Volumes.SelectedLine, len(gui.DockerCommand.Volumes), v) @@ -99,7 +99,7 @@ func (gui *Gui) renderVolumeConfig(mainView *gocui.View, volume *commands.Volume output += utils.WithPadding("Size: ", padding) + utils.FormatBinaryBytes(int(volume.Volume.UsageData.Size)) + "\n" } - _ = gui.renderString(gui.g, "main", output) + _ = gui.renderStringMain(output) }) }