create filtered list struct

This commit is contained in:
Jesse Duffield 2022-10-15 21:45:00 -07:00
parent 57379252a5
commit cb38e48add
10 changed files with 387 additions and 63 deletions

View file

@ -40,11 +40,11 @@ type DockerCommand struct {
ErrorChan chan error ErrorChan chan error
ContainerMutex sync.Mutex ContainerMutex sync.Mutex
ServiceMutex sync.Mutex ServiceMutex sync.Mutex
Services []*Service
Containers []*Container Services []*Service
Containers []*Container
// DisplayContainers is the array of containers we will display in the containers panel. If Gui.ShowAllContainers is false, this will only be those containers which aren't based on a service. This reduces clutter and duplication in the UI // DisplayContainers is the array of containers we will display in the containers panel. If Gui.ShowAllContainers is false, this will only be those containers which aren't based on a service. This reduces clutter and duplication in the UI
DisplayContainers []*Container DisplayContainers []*Container
Images []*Image
Volumes []*Volume Volumes []*Volume
Closers []io.Closer Closers []io.Closer
} }

View file

@ -2,11 +2,9 @@ package commands
import ( import (
"context" "context"
"sort"
"strings" "strings"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/samber/lo"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
@ -103,7 +101,7 @@ func (i *Image) RenderHistory() (string, error) {
} }
// RefreshImages returns a slice of docker images // RefreshImages returns a slice of docker images
func (c *DockerCommand) RefreshImages(filterString string) ([]*Image, error) { func (c *DockerCommand) RefreshImages() ([]*Image, error) {
images, err := c.Client.ImageList(context.Background(), types.ImageListOptions{}) images, err := c.Client.ImageList(context.Background(), types.ImageListOptions{})
if err != nil { if err != nil {
return nil, err return nil, err
@ -145,32 +143,6 @@ func (c *DockerCommand) RefreshImages(filterString string) ([]*Image, error) {
} }
} }
ownImages = lo.Filter(ownImages, func(image *Image, _ int) bool {
return !lo.SomeBy(c.Config.UserConfig.Ignore, func(ignore string) bool {
return strings.Contains(image.Name, ignore)
})
})
if filterString != "" {
ownImages = lo.Filter(ownImages, func(image *Image, _ int) bool {
return strings.Contains(image.Name, filterString)
})
}
noneLabel := "<none>"
sort.Slice(ownImages, func(i, j int) bool {
if ownImages[i].Name == noneLabel && ownImages[j].Name != noneLabel {
return false
}
if ownImages[i].Name != noneLabel && ownImages[j].Name == noneLabel {
return true
}
return ownImages[i].Name < ownImages[j].Name
})
return ownImages, nil return ownImages, nil
} }

88
pkg/gui/filtered_list.go Normal file
View file

@ -0,0 +1,88 @@
package gui
import (
"sort"
"sync"
)
type FilteredList[T comparable] struct {
allItems []T
// indices of items in the allItems slice that are included in the filtered list
indices []int
mutex sync.RWMutex
}
func NewFilteredList[T comparable]() *FilteredList[T] {
return &FilteredList[T]{}
}
func (self *FilteredList[T]) SetItems(items []T) {
self.mutex.Lock()
defer self.mutex.Unlock()
self.allItems = items
self.indices = make([]int, len(items))
for i := range self.indices {
self.indices[i] = i
}
}
func (self *FilteredList[T]) Filter(filter func(T, int) bool) {
self.mutex.Lock()
defer self.mutex.Unlock()
self.indices = self.indices[:0]
for i, item := range self.allItems {
if filter(item, i) {
self.indices = append(self.indices, i)
}
}
}
func (self *FilteredList[T]) Sort(less func(T, T) bool) {
self.mutex.Lock()
defer self.mutex.Unlock()
sort.Slice(self.indices, func(i, j int) bool {
return less(self.allItems[self.indices[i]], self.allItems[self.indices[j]])
})
}
func (self *FilteredList[T]) Get(index int) T {
self.mutex.RLock()
defer self.mutex.RUnlock()
return self.allItems[self.indices[index]]
}
// returns the length of the filtered list
func (self *FilteredList[T]) Len() int {
self.mutex.RLock()
defer self.mutex.RUnlock()
return len(self.indices)
}
func (self *FilteredList[T]) GetIndex(item T) int {
self.mutex.RLock()
defer self.mutex.RUnlock()
for i, index := range self.indices {
if self.allItems[index] == item {
return i
}
}
return -1
}
func (self *FilteredList[T]) GetItems() []T {
self.mutex.RLock()
defer self.mutex.RUnlock()
result := make([]T, len(self.indices))
for i, index := range self.indices {
result[i] = self.allItems[index]
}
return result
}

View file

@ -0,0 +1,183 @@
package gui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFilteredListGet(t *testing.T) {
tests := []struct {
f FilteredList[int]
args int
want int
}{
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: 1,
want: 2,
},
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: 2,
want: 3,
},
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
args: 0,
want: 2,
},
}
for _, tt := range tests {
if got := tt.f.Get(tt.args); got != tt.want {
t.Errorf("FilteredList.Get() = %v, want %v", got, tt.want)
}
}
}
func TestFilteredListLen(t *testing.T) {
tests := []struct {
f FilteredList[int]
want int
}{
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
want: 3,
},
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
want: 1,
},
}
for _, tt := range tests {
if got := tt.f.Len(); got != tt.want {
t.Errorf("FilteredList.Len() = %v, want %v", got, tt.want)
}
}
}
func TestFilteredListFilter(t *testing.T) {
tests := []struct {
f FilteredList[int]
args func(int, int) bool
want FilteredList[int]
}{
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: func(i int, _ int) bool { return i%2 == 0 },
want: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
},
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: func(i int, _ int) bool { return i%2 == 1 },
want: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 2}},
},
}
for _, tt := range tests {
tt.f.Filter(tt.args)
assert.EqualValues(t, tt.f.indices, tt.want.indices)
}
}
func TestFilteredListSort(t *testing.T) {
tests := []struct {
f FilteredList[int]
args func(int, int) bool
want FilteredList[int]
}{
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: func(i int, j int) bool { return i < j },
want: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
},
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: func(i int, j int) bool { return i > j },
want: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{2, 1, 0}},
},
}
for _, tt := range tests {
tt.f.Sort(tt.args)
assert.EqualValues(t, tt.f.indices, tt.want.indices)
}
}
func TestFilteredListGetIndex(t *testing.T) {
tests := []struct {
f FilteredList[int]
args int
want int
}{
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: 1,
want: 0,
},
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: 2,
want: 1,
},
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
args: 0,
want: -1,
},
}
for _, tt := range tests {
if got := tt.f.GetIndex(tt.args); got != tt.want {
t.Errorf("FilteredList.GetIndex() = %v, want %v", got, tt.want)
}
}
}
func TestFilteredListGetItems(t *testing.T) {
tests := []struct {
f FilteredList[int]
want []int
}{
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
want: []int{1, 2, 3},
},
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
want: []int{2},
},
}
for _, tt := range tests {
got := tt.f.GetItems()
assert.EqualValues(t, got, tt.want)
}
}
func TestFilteredListSetItems(t *testing.T) {
tests := []struct {
f FilteredList[int]
args []int
want FilteredList[int]
}{
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: []int{4, 5, 6},
want: FilteredList[int]{allItems: []int{4, 5, 6}, indices: []int{0, 1, 2}},
},
{
f: FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
args: []int{4},
want: FilteredList[int]{allItems: []int{4}, indices: []int{0}},
},
}
for _, tt := range tests {
tt.f.SetItems(tt.args)
assert.EqualValues(t, tt.f.indices, tt.want.indices)
assert.EqualValues(t, tt.f.allItems, tt.want.allItems)
}
}

View file

@ -137,6 +137,16 @@ type guiState struct {
ScreenMode WindowMaximisation ScreenMode WindowMaximisation
Searching searchingState Searching searchingState
Lists Lists
}
// these are the items we display, after filtering is applied.
type Lists struct {
Containers *FilteredList[*commands.Container]
Services *FilteredList[*commands.Service]
Images *FilteredList[*commands.Image]
Volumes *FilteredList[*commands.Volume]
} }
type searchingState struct { type searchingState struct {
@ -173,6 +183,12 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand
Project: &projectState{ContextIndex: 0}, Project: &projectState{ContextIndex: 0},
}, },
ViewStack: []string{}, ViewStack: []string{},
Lists: Lists{
Containers: NewFilteredList[*commands.Container](),
Services: NewFilteredList[*commands.Service](),
Images: NewFilteredList[*commands.Image](),
Volumes: NewFilteredList[*commands.Volume](),
},
} }
cyclableViews := []string{"project", "containers", "images", "volumes"} cyclableViews := []string{"project", "containers", "images", "volumes"}
@ -313,7 +329,7 @@ func (gui *Gui) refresh() {
} }
}() }()
go func() { go func() {
if err := gui.refreshImages(); err != nil { if err := gui.reloadImages(); err != nil {
gui.Log.Error(err) gui.Log.Error(err)
} }
}() }()

View file

@ -12,6 +12,7 @@ import (
"github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils" "github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
) )
// list panel functions // list panel functions
@ -30,11 +31,11 @@ func (gui *Gui) getSelectedImage() (*commands.Image, error) {
return &commands.Image{}, gui.Errors.ErrNoImages return &commands.Image{}, gui.Errors.ErrNoImages
} }
return gui.DockerCommand.Images[selectedLine], nil return gui.State.Lists.Images.Get(selectedLine), nil
} }
func (gui *Gui) handleImagesClick(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleImagesClick(g *gocui.Gui, v *gocui.View) error {
itemCount := len(gui.DockerCommand.Images) itemCount := gui.State.Lists.Images.Len()
handleSelect := gui.handleImageSelect handleSelect := gui.handleImageSelect
selectedLine := &gui.State.Panels.Images.SelectedLine selectedLine := &gui.State.Panels.Images.SelectedLine
@ -50,7 +51,7 @@ func (gui *Gui) handleImageSelect(g *gocui.Gui, v *gocui.View) error {
return gui.renderString(g, "main", gui.Tr.NoImages) return gui.renderString(g, "main", gui.Tr.NoImages)
} }
gui.focusY(gui.State.Panels.Images.SelectedLine, len(gui.DockerCommand.Images), v) 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) { if !gui.shouldRefresh(key) {
@ -97,34 +98,63 @@ func (gui *Gui) renderImageConfig(mainView *gocui.View, image *commands.Image) e
}) })
} }
func (gui *Gui) refreshImages() error { func (gui *Gui) reloadImages() error {
ImagesView := gui.getImagesView()
if ImagesView == nil {
// if the ImagesView hasn't been instantiated yet we just return
return nil
}
if err := gui.refreshStateImages(); err != nil { if err := gui.refreshStateImages(); err != nil {
return err return err
} }
if len(gui.DockerCommand.Images) > 0 && gui.State.Panels.Images.SelectedLine == -1 { return gui.rerenderImages()
}
func (gui *Gui) rerenderImages() error {
filterString := gui.filterString(gui.Views.Images)
gui.State.Lists.Images.Filter(func(image *commands.Image, index int) bool {
if lo.SomeBy(gui.Config.UserConfig.Ignore, func(ignore string) bool {
return strings.Contains(image.Name, ignore)
}) {
return false
}
if filterString != "" {
return strings.Contains(image.Name, filterString)
}
return true
})
noneLabel := "<none>"
gui.State.Lists.Images.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
})
if gui.State.Lists.Images.Len() > 0 && gui.State.Panels.Images.SelectedLine == -1 {
gui.State.Panels.Images.SelectedLine = 0 gui.State.Panels.Images.SelectedLine = 0
} }
if len(gui.DockerCommand.Images)-1 < gui.State.Panels.Images.SelectedLine { if gui.State.Lists.Images.Len()-1 < gui.State.Panels.Images.SelectedLine {
gui.State.Panels.Images.SelectedLine = len(gui.DockerCommand.Images) - 1 gui.State.Panels.Images.SelectedLine = gui.State.Lists.Images.Len() - 1
} }
gui.g.Update(func(g *gocui.Gui) error { gui.g.Update(func(g *gocui.Gui) error {
ImagesView.Clear() gui.Views.Images.Clear()
isFocused := gui.g.CurrentView() == gui.Views.Images isFocused := gui.g.CurrentView() == gui.Views.Images
list, err := utils.RenderList(gui.DockerCommand.Images, utils.IsFocused(isFocused)) list, err := utils.RenderList(gui.State.Lists.Images.GetItems(), utils.IsFocused(isFocused))
if err != nil { if err != nil {
return err return err
} }
fmt.Fprint(ImagesView, list) fmt.Fprint(gui.Views.Images, list)
if ImagesView == g.CurrentView() { if gui.Views.Images == g.CurrentView() {
return gui.handleImageSelect(g, ImagesView) return gui.handleImageSelect(g, gui.Views.Images)
} }
return nil return nil
}) })
@ -132,14 +162,14 @@ func (gui *Gui) refreshImages() error {
return nil return nil
} }
// TODO: leave this to DockerCommand
func (gui *Gui) refreshStateImages() error { func (gui *Gui) refreshStateImages() error {
Images, err := gui.DockerCommand.RefreshImages(gui.filterString(gui.Views.Images)) images, err := gui.DockerCommand.RefreshImages()
if err != nil { if err != nil {
return err return err
} }
gui.DockerCommand.Images = Images // TODO: think about also re-filtering/sorting
gui.State.Lists.Images.SetItems(images)
return nil return nil
} }
@ -158,7 +188,7 @@ func (gui *Gui) handleImagesNextLine(g *gocui.Gui, v *gocui.View) error {
} }
panelState := gui.State.Panels.Images panelState := gui.State.Panels.Images
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.Images), false) gui.changeSelectedLine(&panelState.SelectedLine, gui.State.Lists.Images.Len(), false)
return gui.handleImageSelect(gui.g, v) return gui.handleImageSelect(gui.g, v)
} }
@ -169,7 +199,7 @@ func (gui *Gui) handleImagesPrevLine(g *gocui.Gui, v *gocui.View) error {
} }
panelState := gui.State.Panels.Images panelState := gui.State.Panels.Images
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.Images), true) gui.changeSelectedLine(&panelState.SelectedLine, gui.State.Lists.Images.Len(), true)
return gui.handleImageSelect(gui.g, v) return gui.handleImageSelect(gui.g, v)
} }
@ -274,7 +304,7 @@ func (gui *Gui) handlePruneImages() error {
if err != nil { if err != nil {
return gui.createErrorPanel(err.Error()) return gui.createErrorPanel(err.Error())
} }
return gui.refreshImages() return gui.reloadImages()
}) })
}, nil) }, nil)
} }

View file

@ -516,6 +516,18 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Modifier: gocui.ModNone, Modifier: gocui.ModNone,
Handler: gui.scrollRightMain, Handler: gui.scrollRightMain,
}, },
{
ViewName: "search",
Key: gocui.KeyEnter,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.commitSearch),
},
{
ViewName: "search",
Key: gocui.KeyEsc,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.escapeSearchPrompt),
},
{ {
ViewName: "", ViewName: "",
Key: 'J', Key: 'J',

View file

@ -147,7 +147,7 @@ func (gui *Gui) focusPointInView(view *gocui.View) {
listViews := map[string]listViewState{ listViews := map[string]listViewState{
"containers": {selectedLine: gui.State.Panels.Containers.SelectedLine, lineCount: len(gui.DockerCommand.DisplayContainers)}, "containers": {selectedLine: gui.State.Panels.Containers.SelectedLine, lineCount: len(gui.DockerCommand.DisplayContainers)},
"images": {selectedLine: gui.State.Panels.Images.SelectedLine, lineCount: len(gui.DockerCommand.Images)}, "images": {selectedLine: gui.State.Panels.Images.SelectedLine, lineCount: gui.State.Lists.Images.Len()},
"volumes": {selectedLine: gui.State.Panels.Volumes.SelectedLine, lineCount: len(gui.DockerCommand.Volumes)}, "volumes": {selectedLine: gui.State.Panels.Volumes.SelectedLine, lineCount: len(gui.DockerCommand.Volumes)},
"services": {selectedLine: gui.State.Panels.Services.SelectedLine, lineCount: len(gui.DockerCommand.Services)}, "services": {selectedLine: gui.State.Panels.Services.SelectedLine, lineCount: len(gui.DockerCommand.Services)},
"menu": {selectedLine: gui.State.Panels.Menu.SelectedLine, lineCount: gui.State.MenuItemCount}, "menu": {selectedLine: gui.State.Panels.Menu.SelectedLine, lineCount: gui.State.MenuItemCount},

View file

@ -1,6 +1,8 @@
package gui package gui
import "github.com/jesseduffield/gocui" import (
"github.com/jesseduffield/gocui"
)
func (gui *Gui) handleOpenImageSearch() error { func (gui *Gui) handleOpenImageSearch() error {
return gui.handleOpenSearch(gui.Views.Images) return gui.handleOpenSearch(gui.Views.Images)
@ -10,23 +12,44 @@ func (gui *Gui) handleOpenSearch(view *gocui.View) error {
gui.State.Searching.isSearching = true gui.State.Searching.isSearching = true
gui.State.Searching.view = view gui.State.Searching.view = view
gui.Views.Search.ClearTextArea()
return gui.switchFocus(gui.Views.Search) return gui.switchFocus(gui.Views.Search)
} }
func (gui *Gui) onNewSearchString(value string) error { func (gui *Gui) onNewSearchString(value string) error {
// need to refresh the right list panel. // need to refresh the right list panel.
gui.State.Searching.searchString = value gui.State.Searching.searchString = value
return gui.refreshImages() return gui.rerenderImages()
} }
func (gui *Gui) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { func (gui *Gui) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
matched := f(v, key, ch, mod) matched := f(v, key, ch, mod)
if matched { if matched {
gui.onNewSearchString(v.TextArea.GetContent()) // TODO: handle error
_ = gui.onNewSearchString(v.TextArea.GetContent())
} }
return matched return matched
} }
} }
func (gui *Gui) escapeSearchPrompt() error {
if err := gui.clearSearch(); err != nil {
return err
}
return gui.returnFocus()
}
func (gui *Gui) clearSearch() error {
gui.State.Searching.searchString = ""
gui.State.Searching.isSearching = false
gui.State.Searching.view = nil
gui.Views.Search.ClearTextArea()
return gui.rerenderImages()
}
// returns to the list view with the filter still applied
func (gui *Gui) commitSearch() error {
return gui.returnFocus()
}

View file

@ -137,7 +137,7 @@ func (gui *Gui) createAllViews() error {
gui.Views.Search.FgColor = gocui.ColorGreen gui.Views.Search.FgColor = gocui.ColorGreen
gui.Views.Search.Editable = true gui.Views.Search.Editable = true
gui.Views.Search.Frame = false gui.Views.Search.Frame = false
// gui.Views.Search.Editor = gocui.EditorFunc(gui.wrapEditor(gocui.SimpleEditor)) gui.Views.Search.Editor = gocui.EditorFunc(gui.wrapEditor(gocui.SimpleEditor))
gui.waitForIntro.Done() gui.waitForIntro.Done()