diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index c932e377..05a07e49 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -40,11 +40,11 @@ type DockerCommand struct { ErrorChan chan error ContainerMutex 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 []*Container - Images []*Image Volumes []*Volume Closers []io.Closer } diff --git a/pkg/commands/image.go b/pkg/commands/image.go index fcec2770..0f735623 100644 --- a/pkg/commands/image.go +++ b/pkg/commands/image.go @@ -2,11 +2,9 @@ package commands import ( "context" - "sort" "strings" "github.com/docker/docker/api/types/image" - "github.com/samber/lo" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" @@ -103,7 +101,7 @@ func (i *Image) RenderHistory() (string, error) { } // 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{}) if err != nil { 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 := "" - - 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 } diff --git a/pkg/gui/filtered_list.go b/pkg/gui/filtered_list.go new file mode 100644 index 00000000..b79c2725 --- /dev/null +++ b/pkg/gui/filtered_list.go @@ -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 +} diff --git a/pkg/gui/filtered_list_test.go b/pkg/gui/filtered_list_test.go new file mode 100644 index 00000000..2ff7d37e --- /dev/null +++ b/pkg/gui/filtered_list_test.go @@ -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) + } +} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 613eb101..c8d7d78f 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -137,6 +137,16 @@ type guiState struct { ScreenMode WindowMaximisation 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 { @@ -173,6 +183,12 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand Project: &projectState{ContextIndex: 0}, }, 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"} @@ -313,7 +329,7 @@ func (gui *Gui) refresh() { } }() go func() { - if err := gui.refreshImages(); err != nil { + if err := gui.reloadImages(); err != nil { gui.Log.Error(err) } }() diff --git a/pkg/gui/images_panel.go b/pkg/gui/images_panel.go index f9b24913..733227d7 100644 --- a/pkg/gui/images_panel.go +++ b/pkg/gui/images_panel.go @@ -12,6 +12,7 @@ import ( "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/samber/lo" ) // list panel functions @@ -30,11 +31,11 @@ func (gui *Gui) getSelectedImage() (*commands.Image, error) { 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 { - itemCount := len(gui.DockerCommand.Images) + itemCount := gui.State.Lists.Images.Len() handleSelect := gui.handleImageSelect 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) } - 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] if !gui.shouldRefresh(key) { @@ -97,34 +98,63 @@ func (gui *Gui) renderImageConfig(mainView *gocui.View, image *commands.Image) e }) } -func (gui *Gui) refreshImages() error { - ImagesView := gui.getImagesView() - if ImagesView == nil { - // if the ImagesView hasn't been instantiated yet we just return - return nil - } +func (gui *Gui) reloadImages() error { if err := gui.refreshStateImages(); err != nil { 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 := "" + + 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 } - if len(gui.DockerCommand.Images)-1 < gui.State.Panels.Images.SelectedLine { - gui.State.Panels.Images.SelectedLine = len(gui.DockerCommand.Images) - 1 + if gui.State.Lists.Images.Len()-1 < gui.State.Panels.Images.SelectedLine { + gui.State.Panels.Images.SelectedLine = gui.State.Lists.Images.Len() - 1 } gui.g.Update(func(g *gocui.Gui) error { - ImagesView.Clear() + gui.Views.Images.Clear() 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 { return err } - fmt.Fprint(ImagesView, list) + fmt.Fprint(gui.Views.Images, list) - if ImagesView == g.CurrentView() { - return gui.handleImageSelect(g, ImagesView) + if gui.Views.Images == g.CurrentView() { + return gui.handleImageSelect(g, gui.Views.Images) } return nil }) @@ -132,14 +162,14 @@ func (gui *Gui) refreshImages() error { return nil } -// TODO: leave this to DockerCommand func (gui *Gui) refreshStateImages() error { - Images, err := gui.DockerCommand.RefreshImages(gui.filterString(gui.Views.Images)) + images, err := gui.DockerCommand.RefreshImages() if err != nil { return err } - gui.DockerCommand.Images = Images + // TODO: think about also re-filtering/sorting + gui.State.Lists.Images.SetItems(images) return nil } @@ -158,7 +188,7 @@ func (gui *Gui) handleImagesNextLine(g *gocui.Gui, v *gocui.View) error { } 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) } @@ -169,7 +199,7 @@ func (gui *Gui) handleImagesPrevLine(g *gocui.Gui, v *gocui.View) error { } 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) } @@ -274,7 +304,7 @@ func (gui *Gui) handlePruneImages() error { if err != nil { return gui.createErrorPanel(err.Error()) } - return gui.refreshImages() + return gui.reloadImages() }) }, nil) } diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 78dfa312..18d9ec8d 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -516,6 +516,18 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Modifier: gocui.ModNone, 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: "", Key: 'J', diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 134076ce..1a9b2612 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -147,7 +147,7 @@ func (gui *Gui) focusPointInView(view *gocui.View) { listViews := map[string]listViewState{ "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)}, "services": {selectedLine: gui.State.Panels.Services.SelectedLine, lineCount: len(gui.DockerCommand.Services)}, "menu": {selectedLine: gui.State.Panels.Menu.SelectedLine, lineCount: gui.State.MenuItemCount}, diff --git a/pkg/gui/searching.go b/pkg/gui/searching.go index 53395cf4..eb31f93b 100644 --- a/pkg/gui/searching.go +++ b/pkg/gui/searching.go @@ -1,6 +1,8 @@ package gui -import "github.com/jesseduffield/gocui" +import ( + "github.com/jesseduffield/gocui" +) func (gui *Gui) handleOpenImageSearch() error { 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.view = view - gui.Views.Search.ClearTextArea() - return gui.switchFocus(gui.Views.Search) } func (gui *Gui) onNewSearchString(value string) error { // need to refresh the right list panel. 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 { return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { matched := f(v, key, ch, mod) if matched { - gui.onNewSearchString(v.TextArea.GetContent()) + // TODO: handle error + _ = gui.onNewSearchString(v.TextArea.GetContent()) } 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() +} diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 68cb9c9b..fa27faeb 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -137,7 +137,7 @@ func (gui *Gui) createAllViews() error { gui.Views.Search.FgColor = gocui.ColorGreen gui.Views.Search.Editable = true 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()