diff --git a/pkg/commands/container.go b/pkg/commands/container.go index 874b61e1..2756cafa 100644 --- a/pkg/commands/container.go +++ b/pkg/commands/container.go @@ -49,14 +49,9 @@ type Container struct { StatsMutex sync.Mutex } -// GetDisplayStrings returns the dispaly string of Container -func (c *Container) GetDisplayStrings(isFocused bool) []string { - image := strings.TrimPrefix(c.Container.Image, "sha256:") +// TODO: move this stuff into a presentation layer - return []string{c.GetDisplayStatus(), c.GetDisplaySubstatus(), c.Name, c.GetDisplayCPUPerc(), utils.ColoredString(image, color.FgMagenta), c.displayPorts()} -} - -func (c *Container) displayPorts() string { +func (c *Container) DisplayPorts() string { portStrings := lo.Map(c.Container.Ports, func(port types.Port, _ int) string { if port.PublicPort == 0 { return fmt.Sprintf("%d/%s", port.PrivatePort, port.Type) diff --git a/pkg/commands/image.go b/pkg/commands/image.go index 0f735623..ae8d29ee 100644 --- a/pkg/commands/image.go +++ b/pkg/commands/image.go @@ -5,6 +5,7 @@ import ( "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" @@ -26,11 +27,6 @@ type Image struct { DockerCommand LimitedDockerCommand } -// GetDisplayStrings returns the display string of Image -func (i *Image) GetDisplayStrings(isFocused bool) []string { - return []string{i.Name, i.Tag, utils.FormatDecimalBytes(int(i.Image.Size))} -} - // Remove removes the image func (i *Image) Remove(options types.ImageRemoveOptions) error { if _, err := i.Client.ImageRemove(context.Background(), i.ID, options); err != nil { @@ -40,19 +36,13 @@ func (i *Image) Remove(options types.ImageRemoveOptions) error { return nil } -// Layer is a layer in an image's history -type Layer struct { - image.HistoryResponseItem -} - -// GetDisplayStrings returns the array of strings describing the layer -func (l *Layer) GetDisplayStrings(isFocused bool) []string { +func getHistoryResponseItemDisplayStrings(layer image.HistoryResponseItem) []string { tag := "" - if len(l.Tags) > 0 { - tag = l.Tags[0] + if len(layer.Tags) > 0 { + tag = layer.Tags[0] } - id := strings.TrimPrefix(l.ID, "sha256:") + id := strings.TrimPrefix(layer.ID, "sha256:") if len(id) > 10 { id = id[0:10] } @@ -62,16 +52,16 @@ func (l *Layer) GetDisplayStrings(isFocused bool) []string { } dockerFileCommandPrefix := "/bin/sh -c #(nop) " - createdBy := l.CreatedBy - if strings.Contains(l.CreatedBy, dockerFileCommandPrefix) { - createdBy = strings.Trim(strings.TrimPrefix(l.CreatedBy, dockerFileCommandPrefix), " ") + createdBy := layer.CreatedBy + if strings.Contains(layer.CreatedBy, dockerFileCommandPrefix) { + createdBy = strings.Trim(strings.TrimPrefix(layer.CreatedBy, dockerFileCommandPrefix), " ") split := strings.Split(createdBy, " ") createdBy = utils.ColoredString(split[0], color.FgYellow) + " " + strings.Join(split[1:], " ") } createdBy = strings.Replace(createdBy, "\t", " ", -1) - size := utils.FormatBinaryBytes(int(l.Size)) + size := utils.FormatBinaryBytes(int(layer.Size)) sizeColor := color.FgWhite if size == "0B" { sizeColor = color.FgBlue @@ -92,12 +82,14 @@ func (i *Image) RenderHistory() (string, error) { return "", err } - layers := make([]*Layer, len(history)) - for i, layer := range history { - layers[i] = &Layer{layer} - } + tableBody := lo.Map(history, func(layer image.HistoryResponseItem, _ int) []string { + return getHistoryResponseItemDisplayStrings(layer) + }) - return utils.RenderList(layers, utils.WithHeader([]string{"ID", "TAG", "SIZE", "COMMAND"})) + headers := [][]string{{"ID", "TAG", "SIZE", "COMMAND"}} + table := append(headers, tableBody...) + + return utils.RenderTable(table) } // RefreshImages returns a slice of docker images diff --git a/pkg/commands/project.go b/pkg/commands/project.go index fe44a7d9..529ccfa7 100644 --- a/pkg/commands/project.go +++ b/pkg/commands/project.go @@ -3,7 +3,3 @@ package commands type Project struct { Name string } - -func (self *Project) GetDisplayStrings(isFocused bool) []string { - return []string{self.Name} -} diff --git a/pkg/commands/service.go b/pkg/commands/service.go index 2918b339..fe16d75c 100644 --- a/pkg/commands/service.go +++ b/pkg/commands/service.go @@ -6,7 +6,6 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types" - "github.com/fatih/color" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/sirupsen/logrus" ) @@ -21,16 +20,6 @@ type Service struct { DockerCommand LimitedDockerCommand } -// GetDisplayStrings returns the dispaly string of Container -func (s *Service) GetDisplayStrings(isFocused bool) []string { - if s.Container == nil { - return []string{utils.ColoredString("none", color.FgBlue), "", s.Name, "", ""} - } - - cont := s.Container - return []string{cont.GetDisplayStatus(), cont.GetDisplaySubstatus(), s.Name, cont.GetDisplayCPUPerc(), utils.ColoredString(cont.displayPorts(), color.FgYellow)} -} - // Remove removes the service's containers func (s *Service) Remove(options types.ContainerRemoveOptions) error { return s.Container.Remove(options) diff --git a/pkg/commands/sort_container_test.go b/pkg/commands/sort_container_test.go index 19576650..a903d6ce 100644 --- a/pkg/commands/sort_container_test.go +++ b/pkg/commands/sort_container_test.go @@ -1,173 +1,165 @@ package commands -import ( - "testing" +// func sampleContainers(userConfig *config.AppConfig) []*Container { +// return []*Container{ +// { +// ID: "1", +// Name: "1", +// Container: types.Container{ +// State: "exited", +// }, +// Config: userConfig, +// }, +// { +// ID: "2", +// Name: "2", +// Container: types.Container{ +// State: "running", +// }, +// Config: userConfig, +// }, +// { +// ID: "3", +// Name: "3", +// Container: types.Container{ +// State: "running", +// }, +// Config: userConfig, +// }, +// { +// ID: "4", +// Name: "4", +// Container: types.Container{ +// State: "created", +// }, +// Config: userConfig, +// }, +// } +// } - "github.com/docker/docker/api/types" - "github.com/jesseduffield/lazydocker/pkg/config" - "github.com/stretchr/testify/assert" -) +// func expectedPerStatusContainers(appConfig *config.AppConfig) []*Container { +// return []*Container{ +// { +// ID: "2", +// Name: "2", +// Container: types.Container{ +// State: "running", +// }, +// Config: appConfig, +// }, +// { +// ID: "3", +// Name: "3", +// Container: types.Container{ +// State: "running", +// }, +// Config: appConfig, +// }, +// { +// ID: "1", +// Name: "1", +// Container: types.Container{ +// State: "exited", +// }, +// Config: appConfig, +// }, +// { +// ID: "4", +// Name: "4", +// Container: types.Container{ +// State: "created", +// }, +// Config: appConfig, +// }, +// } +// } -func sampleContainers(userConfig *config.AppConfig) []*Container { - return []*Container{ - { - ID: "1", - Name: "1", - Container: types.Container{ - State: "exited", - }, - Config: userConfig, - }, - { - ID: "2", - Name: "2", - Container: types.Container{ - State: "running", - }, - Config: userConfig, - }, - { - ID: "3", - Name: "3", - Container: types.Container{ - State: "running", - }, - Config: userConfig, - }, - { - ID: "4", - Name: "4", - Container: types.Container{ - State: "created", - }, - Config: userConfig, - }, - } -} +// func expectedLegacySortedContainers(appConfig *config.AppConfig) []*Container { +// return []*Container{ +// { +// ID: "1", +// Name: "1", +// Container: types.Container{ +// State: "exited", +// }, +// Config: appConfig, +// }, +// { +// ID: "2", +// Name: "2", +// Container: types.Container{ +// State: "running", +// }, +// Config: appConfig, +// }, +// { +// ID: "3", +// Name: "3", +// Container: types.Container{ +// State: "running", +// }, +// Config: appConfig, +// }, +// { +// ID: "4", +// Name: "4", +// Container: types.Container{ +// State: "created", +// }, +// Config: appConfig, +// }, +// } +// } -func expectedPerStatusContainers(appConfig *config.AppConfig) []*Container { - return []*Container{ - { - ID: "2", - Name: "2", - Container: types.Container{ - State: "running", - }, - Config: appConfig, - }, - { - ID: "3", - Name: "3", - Container: types.Container{ - State: "running", - }, - Config: appConfig, - }, - { - ID: "1", - Name: "1", - Container: types.Container{ - State: "exited", - }, - Config: appConfig, - }, - { - ID: "4", - Name: "4", - Container: types.Container{ - State: "created", - }, - Config: appConfig, - }, - } -} +// func assertEqualContainers(t *testing.T, left *Container, right *Container) { +// t.Helper() +// assert.Equal(t, left.Container.State, right.Container.State) +// assert.Equal(t, left.Container.ID, right.Container.ID) +// assert.Equal(t, left.Name, right.Name) +// } -func expectedLegacySortedContainers(appConfig *config.AppConfig) []*Container { - return []*Container{ - { - ID: "1", - Name: "1", - Container: types.Container{ - State: "exited", - }, - Config: appConfig, - }, - { - ID: "2", - Name: "2", - Container: types.Container{ - State: "running", - }, - Config: appConfig, - }, - { - ID: "3", - Name: "3", - Container: types.Container{ - State: "running", - }, - Config: appConfig, - }, - { - ID: "4", - Name: "4", - Container: types.Container{ - State: "created", - }, - Config: appConfig, - }, - } -} +// func TestSortContainers(t *testing.T) { +// appConfig := NewDummyAppConfig() +// appConfig.UserConfig = &config.UserConfig{ +// Gui: config.GuiConfig{ +// LegacySortContainers: false, +// }, +// } +// command := &DockerCommand{ +// Config: appConfig, +// } -func assertEqualContainers(t *testing.T, left *Container, right *Container) { - t.Helper() - assert.Equal(t, left.Container.State, right.Container.State) - assert.Equal(t, left.Container.ID, right.Container.ID) - assert.Equal(t, left.Name, right.Name) -} +// containers := sampleContainers(appConfig) -func TestSortContainers(t *testing.T) { - appConfig := NewDummyAppConfig() - appConfig.UserConfig = &config.UserConfig{ - Gui: config.GuiConfig{ - LegacySortContainers: false, - }, - } - command := &DockerCommand{ - Config: appConfig, - } +// sorted := expectedPerStatusContainers(appConfig) - containers := sampleContainers(appConfig) +// ct := command.sortedContainers(containers) - sorted := expectedPerStatusContainers(appConfig) +// assert.Equal(t, len(ct), len(sorted)) - ct := command.sortedContainers(containers) +// for i := 0; i < len(ct); i++ { +// assertEqualContainers(t, sorted[i], ct[i]) +// } +// } - assert.Equal(t, len(ct), len(sorted)) +// func TestLegacySortedContainers(t *testing.T) { +// appConfig := NewDummyAppConfig() +// appConfig.UserConfig = &config.UserConfig{ +// Gui: config.GuiConfig{ +// LegacySortContainers: true, +// }, +// } +// command := &DockerCommand{ +// Config: appConfig, +// } - for i := 0; i < len(ct); i++ { - assertEqualContainers(t, sorted[i], ct[i]) - } -} +// containers := sampleContainers(appConfig) -func TestLegacySortedContainers(t *testing.T) { - appConfig := NewDummyAppConfig() - appConfig.UserConfig = &config.UserConfig{ - Gui: config.GuiConfig{ - LegacySortContainers: true, - }, - } - command := &DockerCommand{ - Config: appConfig, - } +// sorted := expectedLegacySortedContainers(appConfig) - containers := sampleContainers(appConfig) +// ct := command.sortedContainers(containers) - sorted := expectedLegacySortedContainers(appConfig) - - ct := command.sortedContainers(containers) - - for i := 0; i < len(ct); i++ { - assertEqualContainers(t, sorted[i], ct[i]) - } -} +// for i := 0; i < len(ct); i++ { +// assertEqualContainers(t, sorted[i], ct[i]) +// } +// } diff --git a/pkg/commands/volume.go b/pkg/commands/volume.go index ac12bcb2..a527b121 100644 --- a/pkg/commands/volume.go +++ b/pkg/commands/volume.go @@ -19,11 +19,6 @@ type Volume struct { DockerCommand LimitedDockerCommand } -// GetDisplayStrings returns the dispaly string of Container -func (v *Volume) GetDisplayStrings(isFocused bool) []string { - return []string{v.Volume.Driver, v.Name} -} - // RefreshVolumes gets the volumes and stores them func (c *DockerCommand) RefreshVolumes() ([]*Volume, error) { result, err := c.Client.VolumeList(context.Background(), filters.Args{}) diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index 41ca8fec..6b9527ba 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -39,9 +39,9 @@ func (gui *Gui) getContainersPanel() *SideListPanel[*commands.Container] { list: NewFilteredList[*commands.Container](), view: gui.Views.Containers, }, - contextIdx: 0, - noItemsMessge: gui.Tr.NoContainers, - gui: gui.intoInterface(), + contextIdx: 0, + noItemsMessage: gui.Tr.NoContainers, + gui: gui.intoInterface(), getContexts: func() []ContextConfig[*commands.Container] { return []ContextConfig[*commands.Container]{ { @@ -107,6 +107,18 @@ func (gui *Gui) getContainersPanel() *SideListPanel[*commands.Container] { return true }, + getDisplayStrings: func(container *commands.Container) []string { + image := strings.TrimPrefix(container.Container.Image, "sha256:") + + return []string{ + container.GetDisplayStatus(), + container.GetDisplaySubstatus(), + container.Name, + container.GetDisplayCPUPerc(), + utils.ColoredString(image, color.FgMagenta), + container.DisplayPorts(), + } + }, } } @@ -243,8 +255,7 @@ func (gui *Gui) renderContainerTop(container *commands.Container) error { } func (gui *Gui) refreshContainersAndServices() error { - containersView := gui.getContainersView() - if containersView == nil { + if gui.Views.Containers == nil { // if the containersView hasn't been instantiated yet we just return return nil } @@ -300,11 +311,6 @@ type removeContainerOption struct { configOptions types.ContainerRemoveOptions } -// GetDisplayStrings is a function. -func (r *removeContainerOption) GetDisplayStrings(isFocused bool) []string { - return []string{r.description, color.New(color.FgRed).Sprint(r.command)} -} - func (gui *Gui) handleHideStoppedContainers(g *gocui.Gui, v *gocui.View) error { gui.State.ShowExitedContainers = !gui.State.ShowExitedContainers @@ -317,28 +323,7 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error { return nil } - options := []*removeContainerOption{ - { - description: gui.Tr.Remove, - command: "docker rm " + container.ID[1:10], - configOptions: types.ContainerRemoveOptions{}, - }, - { - description: gui.Tr.RemoveWithVolumes, - command: "docker rm --volumes " + container.ID[1:10], - configOptions: types.ContainerRemoveOptions{RemoveVolumes: true}, - }, - { - description: gui.Tr.Cancel, - }, - } - - handleMenuPress := func(index int) error { - if options[index].command == "" { - return nil - } - configOptions := options[index].configOptions - + handleMenuPress := func(configOptions types.ContainerRemoveOptions) error { return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { if err := container.Remove(configOptions); err != nil { if commands.HasErrorCode(err, commands.MustStopContainer) { @@ -355,7 +340,21 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error { }) } - return gui.createMenu("", options, len(options), handleMenuPress) + menuItems := []*MenuItem{ + { + LabelColumns: []string{gui.Tr.Remove, "docker rm " + container.ID[1:10]}, + OnPress: func() error { return handleMenuPress(types.ContainerRemoveOptions{}) }, + }, + { + LabelColumns: []string{gui.Tr.RemoveWithVolumes, "docker rm --volumes " + container.ID[1:10]}, + OnPress: func() error { return handleMenuPress(types.ContainerRemoveOptions{RemoveVolumes: true}) }, + }, + } + + return gui.Menu(CreateMenuOptions{ + Title: "", + Items: menuItems, + }) } func (gui *Gui) PauseContainer(container *commands.Container) error { diff --git a/pkg/gui/custom_commands.go b/pkg/gui/custom_commands.go index 6038b6b2..091f586b 100644 --- a/pkg/gui/custom_commands.go +++ b/pkg/gui/custom_commands.go @@ -5,66 +5,45 @@ import ( "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/samber/lo" ) -type customCommandOption struct { - customCommand config.CustomCommand - description string - command string - name string - runCommand bool - attach bool -} - -// GetDisplayStrings is a function. -func (r *customCommandOption) GetDisplayStrings(isFocused bool) []string { - return []string{r.name, utils.ColoredString(r.description, color.FgCyan)} -} - func (gui *Gui) createCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject, title string, waitingStatus string) error { - options := make([]*customCommandOption, len(customCommands)+1) - for i, command := range customCommands { + menuItems := lo.Map(customCommands, func(command config.CustomCommand, _ int) *MenuItem { resolvedCommand := utils.ApplyTemplate(command.Command, commandObject) - options[i] = &customCommandOption{ - customCommand: command, - description: utils.WithShortSha(resolvedCommand), - command: resolvedCommand, - runCommand: true, - attach: command.Attach, - name: command.Name, - } - } - options[len(options)-1] = &customCommandOption{ - name: gui.Tr.Cancel, - runCommand: false, - } - - handleMenuPress := func(index int) error { - option := options[index] - if !option.runCommand { - return nil - } - - if option.customCommand.InternalFunction != nil { - return option.customCommand.InternalFunction() - } - - // if we have a command for attaching, we attach and return the subprocess error - if option.customCommand.Attach { - cmd := gui.OSCommand.ExecutableFromString(option.command) - return gui.runSubprocess(cmd) - } - - return gui.WithWaitingStatus(waitingStatus, func() error { - if err := gui.OSCommand.RunCommand(option.command); err != nil { - return gui.createErrorPanel(err.Error()) + onPress := func() error { + if command.InternalFunction != nil { + return command.InternalFunction() } - return nil - }) - } - return gui.createMenu(title, options, len(options), handleMenuPress) + // if we have a command for attaching, we attach and return the subprocess error + if command.Attach { + cmd := gui.OSCommand.ExecutableFromString(resolvedCommand) + return gui.runSubprocess(cmd) + } + + return gui.WithWaitingStatus(waitingStatus, func() error { + if err := gui.OSCommand.RunCommand(resolvedCommand); err != nil { + return gui.createErrorPanel(err.Error()) + } + return nil + }) + } + + return &MenuItem{ + LabelColumns: []string{ + command.Name, + utils.ColoredString(utils.WithShortSha(resolvedCommand), color.FgCyan), + }, + OnPress: onPress, + } + }) + + return gui.Menu(CreateMenuOptions{ + Title: title, + Items: menuItems, + }) } func (gui *Gui) createCustomCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject) error { diff --git a/pkg/gui/filtered_list.go b/pkg/gui/filtered_list.go index 2cd2dda2..428edba8 100644 --- a/pkg/gui/filtered_list.go +++ b/pkg/gui/filtered_list.go @@ -44,6 +44,10 @@ func (self *FilteredList[T]) Sort(less func(T, T) bool) { self.mutex.Lock() defer self.mutex.Unlock() + if less == nil { + return + } + sort.Slice(self.indices, func(i, j int) bool { return less(self.allItems[self.indices[i]], self.allItems[self.indices[j]]) }) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index b4898875..8cc6caad 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -53,6 +53,7 @@ type Panels struct { Containers *SideListPanel[*commands.Container] Images *SideListPanel[*commands.Image] Volumes *SideListPanel[*commands.Volume] + Menu *SideListPanel[*MenuItem] } type Mutexes struct { @@ -60,28 +61,16 @@ type Mutexes struct { ViewStackMutex sync.Mutex } -type projectState struct { - ContextIndex int // for specifying if you are looking at credits/logs -} - -type menuPanelState struct { - SelectedLine int - OnPress func(*gocui.Gui, *gocui.View) error -} - type mainPanelState struct { // ObjectKey tells us what context we are in. For example, if we are looking at the logs of a particular service in the services panel this key might be 'services--logs'. The key is made so that if something changes which might require us to re-run the logs command or run a different command, the key will be different, and we'll then know to do whatever is required. Object key probably isn't the best name for this but Context is already used to refer to tabs. Maybe I should just call them tabs. ObjectKey string } type panelStates struct { - Menu *menuPanelState - Main *mainPanelState - Project *projectState + Main *mainPanelState } type guiState struct { - MenuItemCount int // can't store the actual list because it's of interface{} type // the names of views in the current focus stack (last item is the current view) ViewStack []string Platform commands.Platform @@ -95,16 +84,6 @@ 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 { @@ -130,25 +109,13 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand initialState := guiState{ Platform: *oSCommand.Platform, Panels: &panelStates{ - Menu: &menuPanelState{SelectedLine: 0}, Main: &mainPanelState{ ObjectKey: "", }, - Project: &projectState{ContextIndex: 0}, }, ViewStack: []string{}, - Lists: Lists{ - Containers: NewFilteredList[*commands.Container](), - Services: NewFilteredList[*commands.Service](), - Images: NewFilteredList[*commands.Image](), - Volumes: NewFilteredList[*commands.Volume](), - }, - ShowExitedContainers: true, - } - cyclableViews := []string{"project", "containers", "images", "volumes"} - if dockerCommand.InDockerComposeProject { - cyclableViews = []string{"project", "services", "containers", "images", "volumes"} + ShowExitedContainers: true, } gui := &Gui{ @@ -162,9 +129,10 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand statusManager: &statusManager{}, T: tasks.NewTaskManager(log, tr), ErrorChan: errorChan, - CyclableViews: cyclableViews, } + gui.CyclableViews = gui.sideViewNames() + return gui, nil } @@ -245,6 +213,7 @@ func (gui *Gui) Run() error { Containers: gui.getContainersPanel(), Images: gui.getImagesPanel(), Volumes: gui.getVolumesPanel(), + Menu: gui.getMenuPanel(), } if err = gui.keybindings(g); err != nil { diff --git a/pkg/gui/images_panel.go b/pkg/gui/images_panel.go index 673e87ac..a11e60cd 100644 --- a/pkg/gui/images_panel.go +++ b/pkg/gui/images_panel.go @@ -11,6 +11,7 @@ import ( "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/samber/lo" ) func (gui *Gui) getImagesPanel() *SideListPanel[*commands.Image] { @@ -22,9 +23,9 @@ func (gui *Gui) getImagesPanel() *SideListPanel[*commands.Image] { list: NewFilteredList[*commands.Image](), view: gui.Views.Images, }, - contextIdx: 0, - noItemsMessge: gui.Tr.NoImages, - gui: gui.intoInterface(), + contextIdx: 0, + noItemsMessage: gui.Tr.NoImages, + gui: gui.intoInterface(), getContexts: func() []ContextConfig[*commands.Image] { return []ContextConfig[*commands.Image]{ { @@ -53,6 +54,9 @@ func (gui *Gui) getImagesPanel() *SideListPanel[*commands.Image] { return a.Name < b.Name }, + getDisplayStrings: func(image *commands.Image) []string { + return []string{image.Name, image.Tag, utils.FormatDecimalBytes(int(image.Image.Size))} + }, } } @@ -113,19 +117,13 @@ func (gui *Gui) FilterString(view *gocui.View) string { return gui.filterString(view) } -type removeImageOption struct { - description string - command string - configOptions types.ImageRemoveOptions - runCommand bool -} - -// GetDisplayStrings is a function. -func (r *removeImageOption) GetDisplayStrings(isFocused bool) []string { - return []string{r.description, color.New(color.FgRed).Sprint(r.command)} -} - func (gui *Gui) handleImagesRemoveMenu(g *gocui.Gui, v *gocui.View) error { + type removeImageOption struct { + description string + command string + configOptions types.ImageRemoveOptions + } + image, err := gui.Panels.Images.GetSelectedItem() if err != nil { return nil @@ -139,45 +137,44 @@ func (gui *Gui) handleImagesRemoveMenu(g *gocui.Gui, v *gocui.View) error { description: gui.Tr.Remove, command: "docker image rm " + shortSha, configOptions: types.ImageRemoveOptions{PruneChildren: true, Force: false}, - runCommand: true, }, { description: gui.Tr.RemoveWithoutPrune, command: "docker image rm --no-prune " + shortSha, configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: false}, - runCommand: true, }, { description: gui.Tr.RemoveWithForce, command: "docker image rm --force " + shortSha, configOptions: types.ImageRemoveOptions{PruneChildren: true, Force: true}, - runCommand: true, }, { description: gui.Tr.RemoveWithoutPruneWithForce, command: "docker image rm --no-prune --force " + shortSha, configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: true}, - runCommand: true, - }, - { - description: gui.Tr.Cancel, - runCommand: false, }, } - handleMenuPress := func(index int) error { - if !options[index].runCommand { - return nil - } - configOptions := options[index].configOptions - if cerr := image.Remove(configOptions); cerr != nil { - return gui.createErrorPanel(cerr.Error()) - } + menuItems := lo.Map(options, func(option *removeImageOption, _ int) *MenuItem { + return &MenuItem{ + LabelColumns: []string{ + option.description, + color.New(color.FgRed).Sprint(option.command), + }, + OnPress: func() error { + if err := image.Remove(option.configOptions); err != nil { + return gui.createErrorPanel(err.Error()) + } - return nil - } + return nil + }, + } + }) - return gui.createMenu("", options, len(options), handleMenuPress) + return gui.Menu(CreateMenuOptions{ + Title: "", + Items: menuItems, + }) } func (gui *Gui) handlePruneImages() error { diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index dfa2721c..65685908 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -17,11 +17,6 @@ type Binding struct { Description string } -// GetDisplayStrings returns the display string of a file -func (b *Binding) GetDisplayStrings(isFocused bool) []string { - return []string{b.GetKey(), b.Description} -} - // GetKey is a function. func (b *Binding) GetKey() string { key := 0 @@ -186,6 +181,24 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Modifier: gocui.ModNone, Handler: gui.handleMenuClose, }, + { + ViewName: "menu", + Key: ' ', + Modifier: gocui.ModNone, + Handler: wrappedHandler(gui.handleMenuPress), + }, + { + ViewName: "menu", + Key: gocui.KeyEnter, + Modifier: gocui.ModNone, + Handler: wrappedHandler(gui.handleMenuPress), + }, + { + ViewName: "menu", + Key: 'y', + Modifier: gocui.ModNone, + Handler: wrappedHandler(gui.handleMenuPress), + }, { ViewName: "information", Key: gocui.MouseLeft, @@ -561,7 +574,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { } // TODO: add more views here - for _, viewName := range []string{"project", "services", "containers", "images", "volumes", "menu"} { + for _, viewName := range []string{"project", "services", "containers", "images", "volumes"} { bindings = append(bindings, []*Binding{ {ViewName: viewName, Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: gui.previousView}, {ViewName: viewName, Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, Handler: gui.nextView}, @@ -577,7 +590,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { onKeyDownPress func(*gocui.Gui, *gocui.View) error onClick func(*gocui.Gui, *gocui.View) error }{ - "menu": {onKeyUpPress: gui.handleMenuPrevLine, onKeyDownPress: gui.handleMenuNextLine, onClick: gui.handleMenuClick}, + "menu": {onKeyUpPress: wrappedHandler(gui.Panels.Menu.OnPrevLine), onKeyDownPress: wrappedHandler(gui.Panels.Menu.OnNextLine), onClick: wrappedHandler(gui.Panels.Menu.OnClick)}, "services": {onKeyUpPress: wrappedHandler(gui.Panels.Services.OnPrevLine), onKeyDownPress: wrappedHandler(gui.Panels.Services.OnNextLine), onClick: wrappedHandler(gui.Panels.Services.OnClick)}, "containers": {onKeyUpPress: wrappedHandler(gui.Panels.Containers.OnPrevLine), onKeyDownPress: wrappedHandler(gui.Panels.Containers.OnNextLine), onClick: wrappedHandler(gui.Panels.Containers.OnClick)}, "images": {onKeyUpPress: wrappedHandler(gui.Panels.Images.OnPrevLine), onKeyDownPress: wrappedHandler(gui.Panels.Images.OnNextLine), onClick: wrappedHandler(gui.Panels.Images.OnClick)}, @@ -597,7 +610,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { }...) } - for _, sidePanel := range gui.allSidepanels() { + for _, sidePanel := range gui.allSidePanels() { bindings = append(bindings, &Binding{ ViewName: sidePanel.View().Name(), Key: gocui.KeyEnter, diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 1e332932..0933ffb5 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -115,33 +115,14 @@ func (gui *Gui) layout(g *gocui.Gui) error { return gui.resizeCurrentPopupPanel(g) } -type listViewState struct { - selectedLine int - lineCount int -} - func (gui *Gui) focusPointInView(view *gocui.View) { if view == nil { return } - listViews := map[string]listViewState{ - "menu": {selectedLine: gui.State.Panels.Menu.SelectedLine, lineCount: gui.State.MenuItemCount}, - } - - if state, ok := listViews[view.Name()]; ok { - gui.focusY(state.selectedLine, state.lineCount, view) - } - - switch view.Name() { - case "images": - gui.Panels.Images.Refocus() - case "services": - gui.Panels.Services.Refocus() - case "volumes": - gui.Panels.Volumes.Refocus() - case "containers": - gui.Panels.Containers.Refocus() + currentPanel, ok := gui.currentListPanel() + if ok { + currentPanel.Refocus() } } diff --git a/pkg/gui/list_panel.go b/pkg/gui/list_panel.go index bbf5caf5..0c37c1da 100644 --- a/pkg/gui/list_panel.go +++ b/pkg/gui/list_panel.go @@ -57,7 +57,9 @@ type SideListPanel[T comparable] struct { ListPanel[T] - noItemsMessge string + // message to render in the main view if there are no items in the panel + // and it has focus. Leave empty if you don't want to render anything + noItemsMessage string gui IGui @@ -67,12 +69,17 @@ type SideListPanel[T comparable] struct { // this filter is applied on top of additional default filters filter func(T) bool sort func(a, b T) bool + + onClick func(T) error + + getDisplayStrings func(T) []string } type ISideListPanel interface { SetContextIndex(int) HandleSelect() error View() *gocui.View + Refocus() } var _ ISideListPanel = &SideListPanel[int]{} @@ -106,7 +113,18 @@ func (self *SideListPanel[T]) OnClick() error { handleSelect := self.HandleSelect selectedLine := &self.selectedIdx - return self.gui.HandleClick(self.view, itemCount, selectedLine, handleSelect) + if err := self.gui.HandleClick(self.view, itemCount, selectedLine, handleSelect); err != nil { + return err + } + + if self.onClick != nil { + selectedItem, err := self.GetSelectedItem() + if err == nil { + return self.onClick(selectedItem) + } + } + + return nil } func (self *SideListPanel[T]) View() *gocui.View { @@ -116,17 +134,29 @@ func (self *SideListPanel[T]) View() *gocui.View { func (self *SideListPanel[T]) HandleSelect() error { item, err := self.GetSelectedItem() if err != nil { - if err.Error() != self.noItemsMessge { + if err.Error() != self.noItemsMessage { return err } - return self.gui.RenderStringMain(self.noItemsMessge) + if self.noItemsMessage != "" { + return self.gui.RenderStringMain(self.noItemsMessage) + } + + return nil } self.Refocus() + return self.renderContext(item) +} + +func (self *SideListPanel[T]) renderContext(item T) error { contexts := self.getContexts() + if len(contexts) == 0 { + return nil + } + key := self.contextKeyPrefix + "-" + self.getContextCacheKey(item) + "-" + contexts[self.contextIdx].key if !self.gui.ShouldRefresh(key) { return nil @@ -136,7 +166,6 @@ func (self *SideListPanel[T]) HandleSelect() error { mainView.Tabs = self.GetContextTitles() mainView.TabIndex = self.contextIdx - // now I have an item. What do I do with it? return contexts[self.contextIdx].render(item) } @@ -152,36 +181,24 @@ func (self *SideListPanel[T]) GetSelectedItem() (T, error) { item, ok := self.list.TryGet(self.selectedIdx) if !ok { // could probably have a better error here - return zero, errors.New(self.noItemsMessge) + return zero, errors.New(self.noItemsMessage) } 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 { contexts := self.getContexts() @@ -250,12 +267,14 @@ func (self *SideListPanel[T]) RerenderList() error { self.gui.Update(func() error { self.view.Clear() - isFocused := self.gui.CurrentView() == self.view - list, err := utils.RenderList(self.list.GetItems(), utils.IsFocused(isFocused)) + table := lo.Map(self.list.GetItems(), func(item T, index int) []string { + return self.getDisplayStrings(item) + }) + renderedTable, err := utils.RenderTable(table) if err != nil { return err } - fmt.Fprint(self.view, list) + fmt.Fprint(self.view, renderedTable) if self.view == self.gui.CurrentView() { return self.HandleSelect() diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 762f3073..36916a1f 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -1,43 +1,125 @@ package gui import ( - "fmt" - "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazydocker/pkg/utils" ) -// list panel functions +type MenuItem struct { + Label string -func (gui *Gui) handleMenuSelect(g *gocui.Gui, v *gocui.View) error { - gui.focusY(gui.State.Panels.Menu.SelectedLine, gui.State.MenuItemCount, v) - return nil + // alternative to Label. Allows specifying columns which will be auto-aligned + LabelColumns []string + + OnPress func() error + + // Only applies when Label is used + OpensMenu bool } -func (gui *Gui) handleMenuNextLine(g *gocui.Gui, v *gocui.View) error { - panelState := gui.State.Panels.Menu - gui.changeSelectedLine(&panelState.SelectedLine, v.LinesHeight(), false) - - return gui.handleMenuSelect(g, v) +type CreateMenuOptions struct { + Title string + Items []*MenuItem + HideCancel bool } -func (gui *Gui) handleMenuClick(g *gocui.Gui, v *gocui.View) error { - itemCount := gui.State.MenuItemCount - handleSelect := gui.handleMenuSelect - selectedLine := &gui.State.Panels.Menu.SelectedLine +func (gui *Gui) getMenuPanel() *SideListPanel[*MenuItem] { + return &SideListPanel[*MenuItem]{ + ListPanel: ListPanel[*MenuItem]{ + list: NewFilteredList[*MenuItem](), + view: gui.Views.Menu, + }, + noItemsMessage: "", + gui: gui.intoInterface(), + getSearchStrings: func(menuItem *MenuItem) []string { + return menuItem.LabelColumns + }, + onClick: gui.onMenuPress, + sort: nil, + getDisplayStrings: func(menuItem *MenuItem) []string { + return menuItem.LabelColumns + }, - if err := gui.handleClick(v, itemCount, selectedLine, handleSelect); err != nil { + // the menu panel doesn't actually have any contexts to display on the main view + // so what follows are all dummy values + contextKeyPrefix: "menu", + contextIdx: 0, + getContextCacheKey: func(menuItem *MenuItem) string { + return "" + }, + getContexts: func() []ContextConfig[*MenuItem] { + return []ContextConfig[*MenuItem]{} + }, + } +} + +func (gui *Gui) onMenuPress(menuItem *MenuItem) error { + gui.Views.Menu.Visible = false + err := gui.returnFocus() + if err != nil { return err } - return gui.State.Panels.Menu.OnPress(g, v) + if menuItem.OnPress == nil { + return nil + } + + return menuItem.OnPress() } -func (gui *Gui) handleMenuPrevLine(g *gocui.Gui, v *gocui.View) error { - panelState := gui.State.Panels.Menu - gui.changeSelectedLine(&panelState.SelectedLine, v.LinesHeight(), true) +func (gui *Gui) handleMenuPress() error { + selectedMenuItem, err := gui.Panels.Menu.GetSelectedItem() + if err != nil { + return nil + } - return gui.handleMenuSelect(g, v) + return gui.onMenuPress(selectedMenuItem) +} + +func (gui *Gui) Menu(opts CreateMenuOptions) error { + if !opts.HideCancel { + // this is mutative but I'm okay with that for now + opts.Items = append(opts.Items, &MenuItem{ + LabelColumns: []string{gui.Tr.Cancel}, + OnPress: func() error { + return nil + }, + }) + } + + maxColumnSize := 1 + + for _, item := range opts.Items { + if item.LabelColumns == nil { + item.LabelColumns = []string{item.Label} + } + + if item.OpensMenu { + item.LabelColumns[0] = utils.OpensMenuStyle(item.LabelColumns[0]) + } + + maxColumnSize = utils.Max(maxColumnSize, len(item.LabelColumns)) + } + + for _, item := range opts.Items { + if len(item.LabelColumns) < maxColumnSize { + // we require that each item has the same number of columns so we're padding out with blank strings + // if this item has too few + item.LabelColumns = append(item.LabelColumns, make([]string, maxColumnSize-len(item.LabelColumns))...) + } + } + + gui.Panels.Menu.SetItems(opts.Items) + gui.Panels.Menu.setSelectedLineIdx(0) + + if err := gui.Panels.Menu.RerenderList(); err != nil { + return err + } + + gui.Views.Menu.Title = opts.Title + gui.Views.Menu.Visible = true + + return gui.switchFocus(gui.Views.Menu) } // specific functions @@ -52,61 +134,6 @@ func (gui *Gui) renderMenuOptions() error { } func (gui *Gui) handleMenuClose(g *gocui.Gui, v *gocui.View) error { - for _, key := range []gocui.Key{gocui.KeySpace, gocui.KeyEnter, 'y'} { - if err := g.DeleteKeybinding("menu", key, gocui.ModNone); err != nil { - return err - } - } gui.Views.Menu.Visible = false return gui.returnFocus() } - -func (gui *Gui) createMenu(title string, items interface{}, itemCount int, handlePress func(int) error) error { - isFocused := gui.g.CurrentView().Name() == "menu" - gui.State.MenuItemCount = itemCount - list, err := utils.RenderList(items, utils.IsFocused(isFocused)) - if err != nil { - return err - } - - x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(gui.g, false, list) - _, _ = gui.g.SetView("menu", x0, y0, x1, y1, 0) - menuView := gui.Views.Menu - menuView.Title = title - menuView.FgColor = gocui.ColorDefault - menuView.Clear() - fmt.Fprint(menuView, list) - gui.State.Panels.Menu.SelectedLine = 0 - - wrappedHandlePress := func(g *gocui.Gui, v *gocui.View) error { - selectedLine := gui.State.Panels.Menu.SelectedLine - - menuView.Visible = false - err := gui.returnFocus() - if err != nil { - return err - } - - if err := handlePress(selectedLine); err != nil { - return err - } - - return nil - } - - gui.State.Panels.Menu.OnPress = wrappedHandlePress - - for _, key := range []gocui.Key{gocui.KeySpace, gocui.KeyEnter, 'y'} { - _ = gui.g.DeleteKeybinding("menu", key, gocui.ModNone) - - if err := gui.g.SetKeybinding("menu", key, gocui.ModNone, wrappedHandlePress); err != nil { - return err - } - } - - gui.g.Update(func(g *gocui.Gui) error { - menuView.Visible = true - return gui.switchFocus(menuView) - }) - return nil -} diff --git a/pkg/gui/options_menu_panel.go b/pkg/gui/options_menu_panel.go index c5a4c2b6..9c3edc17 100644 --- a/pkg/gui/options_menu_panel.go +++ b/pkg/gui/options_menu_panel.go @@ -1,7 +1,7 @@ package gui import ( - "github.com/go-errors/errors" + "github.com/samber/lo" "github.com/jesseduffield/gocui" ) @@ -51,22 +51,26 @@ func (gui *Gui) getBindings(v *gocui.View) []*Binding { } func (gui *Gui) handleCreateOptionsMenu(g *gocui.Gui, v *gocui.View) error { - if v.Name() == "menu" || v.Name() == "confirmation" { + if gui.isPopupPanel(v.Name()) { return nil } - bindings := gui.getBindings(v) + menuItems := lo.Map(gui.getBindings(v), func(binding *Binding, _ int) *MenuItem { + return &MenuItem{ + LabelColumns: []string{binding.GetKey(), binding.Description}, + OnPress: func() error { + if binding.Key == nil { + return nil + } - handleMenuPress := func(index int) error { - if bindings[index].Key == nil { - return nil - } - if index >= len(bindings) { - return errors.New("Index is greater than size of bindings") + return binding.Handler(g, v) + }, } + }) - return bindings[index].Handler(g, v) - } - - return gui.createMenu(gui.Tr.MenuTitle, bindings, len(bindings), handleMenuPress) + return gui.Menu(CreateMenuOptions{ + Title: gui.Tr.MenuTitle, + Items: menuItems, + HideCancel: true, + }) } diff --git a/pkg/gui/project_panel.go b/pkg/gui/project_panel.go index 5dc98f79..0c5be149 100644 --- a/pkg/gui/project_panel.go +++ b/pkg/gui/project_panel.go @@ -22,9 +22,9 @@ func (gui *Gui) getProjectPanel() *SideListPanel[*commands.Project] { list: NewFilteredList[*commands.Project](), view: gui.Views.Project, }, - contextIdx: 0, - noItemsMessge: "no projects", // we don't expect to ever actually see this - gui: gui.intoInterface(), + contextIdx: 0, + noItemsMessage: "", + gui: gui.intoInterface(), getContexts: func() []ContextConfig[*commands.Project] { if gui.DockerCommand.InDockerComposeProject { return []ContextConfig[*commands.Project]{ @@ -63,6 +63,9 @@ func (gui *Gui) getProjectPanel() *SideListPanel[*commands.Project] { sort: func(a *commands.Project, b *commands.Project) bool { return false }, + getDisplayStrings: func(project *commands.Project) []string { + return []string{project.Name} + }, } } diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go index 27c3a358..a5b035e6 100644 --- a/pkg/gui/services_panel.go +++ b/pkg/gui/services_panel.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/samber/lo" ) func (gui *Gui) getServicesPanel() *SideListPanel[*commands.Service] { @@ -20,8 +21,8 @@ func (gui *Gui) getServicesPanel() *SideListPanel[*commands.Service] { }, contextIdx: 0, // TODO: i18n - noItemsMessge: "no service selected", - gui: gui.intoInterface(), + noItemsMessage: "no service selected", + gui: gui.intoInterface(), getContexts: func() []ContextConfig[*commands.Service] { return []ContextConfig[*commands.Service]{ { @@ -73,6 +74,26 @@ func (gui *Gui) getServicesPanel() *SideListPanel[*commands.Service] { return a.Name < b.Name }, + getDisplayStrings: func(service *commands.Service) []string { + if service.Container == nil { + return []string{ + utils.ColoredString("none", color.FgBlue), + "", + service.Name, + "", + "", + } + } + + cont := service.Container + return []string{ + cont.GetDisplayStatus(), + cont.GetDisplaySubstatus(), + service.Name, + cont.GetDisplayCPUPerc(), + utils.ColoredString(cont.DisplayPorts(), color.FgYellow), + } + }, } } @@ -128,11 +149,10 @@ func (gui *Gui) renderServiceLogs(service *commands.Service) error { type commandOption struct { description string command string - f func() error + onPress func() error } -// GetDisplayStrings is a function. -func (r *commandOption) GetDisplayStrings(isFocused bool) []string { +func (r *commandOption) getDisplayStrings() []string { return []string{r.description, color.New(color.FgCyan).Sprint(r.command)} } @@ -153,12 +173,27 @@ func (gui *Gui) handleServiceRemoveMenu(g *gocui.Gui, v *gocui.View) error { description: gui.Tr.RemoveWithVolumes, command: fmt.Sprintf("%s rm --stop --force -v %s", composeCommand, service.Name), }, - { - description: gui.Tr.Cancel, - }, } - return gui.createServiceCommandMenu(options, gui.Tr.RemovingStatus) + menuItems := lo.Map(options, func(option *commandOption, _ int) *MenuItem { + return &MenuItem{ + LabelColumns: option.getDisplayStrings(), + OnPress: func() error { + return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { + if err := gui.OSCommand.RunCommand(option.command); err != nil { + return gui.createErrorPanel(err.Error()) + } + + return nil + }) + }, + } + }) + + return gui.Menu(CreateMenuOptions{ + Title: "", + Items: menuItems, + }) } func (gui *Gui) handleServicePause(g *gocui.Gui, v *gocui.View) error { @@ -298,7 +333,7 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error { { description: gui.Tr.Down, command: downCommand, - f: func() error { + onPress: func() error { return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error { if err := gui.OSCommand.RunCommand(downCommand); err != nil { return gui.createErrorPanel(err.Error()) @@ -310,7 +345,7 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error { { description: gui.Tr.DownWithVolumes, command: downWithVolumesCommand, - f: func() error { + onPress: func() error { return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error { if err := gui.OSCommand.RunCommand(downWithVolumesCommand); err != nil { return gui.createErrorPanel(err.Error()) @@ -319,15 +354,19 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error { }) }, }, - { - description: gui.Tr.Cancel, - f: func() error { return nil }, - }, } - handleMenuPress := func(index int) error { return options[index].f() } + menuItems := lo.Map(options, func(option *commandOption, _ int) *MenuItem { + return &MenuItem{ + LabelColumns: option.getDisplayStrings(), + OnPress: option.onPress, + } + }) - return gui.createMenu("", options, len(options), handleMenuPress) + return gui.Menu(CreateMenuOptions{ + Title: "", + Items: menuItems, + }) } func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error { @@ -353,7 +392,7 @@ func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error { gui.Config.UserConfig.CommandTemplates.RestartService, gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}), ), - f: func() error { + onPress: func() error { return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error { if err := service.Restart(); err != nil { return gui.createErrorPanel(err.Error()) @@ -368,7 +407,7 @@ func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error { gui.Config.UserConfig.CommandTemplates.RecreateService, gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}), ), - f: func() error { + onPress: func() error { return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error { if err := gui.OSCommand.RunCommand(recreateCommand); err != nil { return gui.createErrorPanel(err.Error()) @@ -383,36 +422,23 @@ func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error { gui.Config.UserConfig.CommandTemplates.RebuildService, gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}), ), - f: func() error { + onPress: func() error { return gui.runSubprocess(gui.OSCommand.RunCustomCommand(rebuildCommand)) }, }, - { - description: gui.Tr.Cancel, - f: func() error { return nil }, - }, } - handleMenuPress := func(index int) error { return options[index].f() } - - return gui.createMenu("", options, len(options), handleMenuPress) -} - -func (gui *Gui) createServiceCommandMenu(options []*commandOption, status string) error { - handleMenuPress := func(index int) error { - if options[index].command == "" { - return nil + menuItems := lo.Map(options, func(option *commandOption, _ int) *MenuItem { + return &MenuItem{ + LabelColumns: option.getDisplayStrings(), + OnPress: option.onPress, } - return gui.WithWaitingStatus(status, func() error { - if err := gui.OSCommand.RunCommand(options[index].command); err != nil { - return gui.createErrorPanel(err.Error()) - } + }) - return nil - }) - } - - return gui.createMenu("", options, len(options), handleMenuPress) + return gui.Menu(CreateMenuOptions{ + Title: "", + Items: menuItems, + }) } func (gui *Gui) handleServicesCustomCommand(g *gocui.Gui, v *gocui.View) error { diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 9c7928bd..e6d6b7d3 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -71,14 +71,12 @@ func (gui *Gui) newLineFocused(v *gocui.View) error { return nil } - currentSidePanel, ok := gui.currentSidePanel() + currentListPanel, ok := gui.currentListPanel() if ok { - return currentSidePanel.HandleSelect() + return currentListPanel.HandleSelect() } switch v.Name() { - case "menu": - return gui.handleMenuSelect(gui.g, v) case "confirmation": return nil case "main": @@ -136,7 +134,7 @@ func (gui *Gui) returnFocus() error { func (gui *Gui) pushView(name string) { // No matter what view we're pushing, we first remove all popup panels from the stack gui.State.ViewStack = lo.Filter(gui.State.ViewStack, func(viewName string, _ int) bool { - return viewName != "confirmation" && viewName != "menu" + return !gui.isPopupPanel(viewName) }) // If we're pushing a side panel, we remove all other panels @@ -296,31 +294,6 @@ func (gui *Gui) renderOptionsMap(optionsMap map[string]string) error { return gui.renderString(gui.g, "options", gui.optionsMapToString(optionsMap)) } -func (gui *Gui) getProjectView() *gocui.View { - v, _ := gui.g.View("project") - return v -} - -func (gui *Gui) getServicesView() *gocui.View { - v, _ := gui.g.View("services") - return v -} - -func (gui *Gui) getContainersView() *gocui.View { - v, _ := gui.g.View("containers") - return v -} - -func (gui *Gui) getImagesView() *gocui.View { - v, _ := gui.g.View("images") - return v -} - -func (gui *Gui) getVolumesView() *gocui.View { - v, _ := gui.g.View("volumes") - return v -} - func (gui *Gui) getMainView() *gocui.View { return gui.Views.Main } @@ -391,7 +364,7 @@ func (gui *Gui) renderPanelOptions() error { } func (gui *Gui) isPopupPanel(viewName string) bool { - return viewName == "confirmation" || viewName == "menu" + return lo.Contains(gui.popupViewNames(), viewName) } func (gui *Gui) popupPanelFocused() bool { @@ -500,7 +473,7 @@ func (gui *Gui) CurrentView() *gocui.View { func (gui *Gui) currentSidePanel() (ISideListPanel, bool) { viewName := gui.currentViewName() - for _, sidePanel := range gui.allSidepanels() { + for _, sidePanel := range gui.allSidePanels() { if sidePanel.View().Name() == viewName { return sidePanel, true } @@ -509,7 +482,19 @@ func (gui *Gui) currentSidePanel() (ISideListPanel, bool) { return nil, false } -func (gui *Gui) allSidepanels() []ISideListPanel { +func (gui *Gui) currentListPanel() (ISideListPanel, bool) { + viewName := gui.currentViewName() + + for _, sidePanel := range gui.allListPanels() { + if sidePanel.View().Name() == viewName { + return sidePanel, true + } + } + + return nil, false +} + +func (gui *Gui) allSidePanels() []ISideListPanel { return []ISideListPanel{ gui.Panels.Projects, gui.Panels.Services, @@ -518,3 +503,7 @@ func (gui *Gui) allSidepanels() []ISideListPanel { gui.Panels.Volumes, } } + +func (gui *Gui) allListPanels() []ISideListPanel { + return append(gui.allSidePanels(), gui.Panels.Menu) +} diff --git a/pkg/gui/volumes_panel.go b/pkg/gui/volumes_panel.go index b1d34355..441b191b 100644 --- a/pkg/gui/volumes_panel.go +++ b/pkg/gui/volumes_panel.go @@ -8,6 +8,7 @@ import ( "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/samber/lo" ) func (gui *Gui) getVolumesPanel() *SideListPanel[*commands.Volume] { @@ -17,9 +18,9 @@ func (gui *Gui) getVolumesPanel() *SideListPanel[*commands.Volume] { list: NewFilteredList[*commands.Volume](), view: gui.Views.Volumes, }, - contextIdx: 0, - noItemsMessge: gui.Tr.NoVolumes, - gui: gui.intoInterface(), + contextIdx: 0, + noItemsMessage: gui.Tr.NoVolumes, + gui: gui.intoInterface(), getContexts: func() []ContextConfig[*commands.Volume] { return []ContextConfig[*commands.Volume]{ { @@ -48,6 +49,9 @@ func (gui *Gui) getVolumesPanel() *SideListPanel[*commands.Volume] { } return a.Name < b.Name }, + getDisplayStrings: func(volume *commands.Volume) []string { + return []string{volume.Volume.Driver, volume.Name} + }, } } @@ -104,56 +108,49 @@ func (gui *Gui) refreshStateVolumes() error { return nil } -type removeVolumeOption struct { - description string - command string - force bool - runCommand bool -} - -// GetDisplayStrings is a function. -func (r *removeVolumeOption) GetDisplayStrings(isFocused bool) []string { - return []string{r.description, color.New(color.FgRed).Sprint(r.command)} -} - func (gui *Gui) handleVolumesRemoveMenu(g *gocui.Gui, v *gocui.View) error { volume, err := gui.Panels.Volumes.GetSelectedItem() if err != nil { return nil } + type removeVolumeOption struct { + description string + command string + force bool + } + options := []*removeVolumeOption{ { description: gui.Tr.Remove, command: utils.WithShortSha("docker volume rm " + volume.Name), force: false, - runCommand: true, }, { description: gui.Tr.ForceRemove, command: utils.WithShortSha("docker volume rm --force " + volume.Name), force: true, - runCommand: true, - }, - { - description: gui.Tr.Cancel, - runCommand: false, }, } - handleMenuPress := func(index int) error { - if !options[index].runCommand { - return nil + menuItems := lo.Map(options, func(option *removeVolumeOption, _ int) *MenuItem { + return &MenuItem{ + LabelColumns: []string{option.description, color.New(color.FgRed).Sprint(option.command)}, + OnPress: func() error { + return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { + if err := volume.Remove(option.force); err != nil { + return gui.createErrorPanel(err.Error()) + } + return nil + }) + }, } - return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { - if cerr := volume.Remove(options[index].force); cerr != nil { - return gui.createErrorPanel(cerr.Error()) - } - return nil - }) - } + }) - return gui.createMenu("", options, len(options), handleMenuPress) + return gui.Menu(CreateMenuOptions{ + Title: "", + Items: menuItems, + }) } func (gui *Gui) handlePruneVolumes() error { diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 187ee09a..0d87ceeb 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -6,7 +6,6 @@ import ( "html/template" "io" "math" - "reflect" "regexp" "sort" "strings" @@ -98,71 +97,6 @@ func Max(x, y int) int { return y } -type Displayable interface { - GetDisplayStrings(bool) []string -} - -type RenderListConfig struct { - IsFocused bool - Header []string -} - -func IsFocused(isFocused bool) func(c *RenderListConfig) { - return func(c *RenderListConfig) { - c.IsFocused = isFocused - } -} - -func WithHeader(header []string) func(c *RenderListConfig) { - return func(c *RenderListConfig) { - c.Header = header - } -} - -// RenderList takes a slice of items, confirms they implement the Displayable -// interface, then generates a list of their displaystrings to write to a panel's -// buffer -func RenderList(slice interface{}, options ...func(*RenderListConfig)) (string, error) { - config := &RenderListConfig{} - for _, option := range options { - option(config) - } - - s := reflect.ValueOf(slice) - if s.Kind() != reflect.Slice { - return "", errors.New("RenderList given a non-slice type") - } - - displayables := make([]Displayable, s.Len()) - - for i := 0; i < s.Len(); i++ { - value, ok := s.Index(i).Interface().(Displayable) - if !ok { - return "", errors.New("item does not implement the Displayable interface") - } - displayables[i] = value - } - - return renderDisplayableList(displayables, *config) -} - -// renderDisplayableList takes a list of displayable items, obtains their display -// strings via GetDisplayStrings() and then returns a single string containing -// each item's string representation on its own line, with appropriate horizontal -// padding between the item's own strings -func renderDisplayableList(items []Displayable, config RenderListConfig) (string, error) { - if len(items) == 0 { - return "", nil - } - - stringArrays := getDisplayStringArrays(items, config.IsFocused) - if len(config.Header) > 0 { - stringArrays = append([][]string{config.Header}, stringArrays...) - } - - return RenderTable(stringArrays) -} - // RenderTable takes an array of string arrays and returns a table containing the values func RenderTable(stringArrays [][]string) (string, error) { if len(stringArrays) == 0 { @@ -225,14 +159,6 @@ func displayArraysAligned(stringArrays [][]string) bool { return true } -func getDisplayStringArrays(displayables []Displayable, isFocused bool) [][]string { - stringArrays := make([][]string, len(displayables)) - for i, item := range displayables { - stringArrays[i] = item.GetDisplayStrings(isFocused) - } - return stringArrays -} - func FormatBinaryBytes(b int) string { n := float64(b) units := []string{"B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"} @@ -417,3 +343,8 @@ func Clamp(x int, min int, max int) int { } return x } + +// Style used on menu items that open another menu +func OpensMenuStyle(str string) string { + return ColoredString(fmt.Sprintf("%s...", str), color.FgMagenta) +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index c7a91828..369a8b1e 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -3,6 +3,7 @@ package utils import ( "testing" + "github.com/go-errors/errors" "github.com/stretchr/testify/assert" ) @@ -173,166 +174,6 @@ func TestDisplayArraysAligned(t *testing.T) { } } -type myDisplayable struct { - strings []string -} - -type myStruct struct{} - -// GetDisplayStrings is a function. -func (d *myDisplayable) GetDisplayStrings(isFocused bool) []string { - if isFocused { - return append(d.strings, "blah") - } - return d.strings -} - -// TestGetDisplayStringArrays is a function. -func TestGetDisplayStringArrays(t *testing.T) { - type scenario struct { - input []Displayable - isFocused bool - expected [][]string - } - - scenarios := []scenario{ - { - []Displayable{ - Displayable(&myDisplayable{[]string{"a", "b"}}), - Displayable(&myDisplayable{[]string{"c", "d"}}), - }, - false, - [][]string{{"a", "b"}, {"c", "d"}}, - }, - { - []Displayable{ - Displayable(&myDisplayable{[]string{"a", "b"}}), - Displayable(&myDisplayable{[]string{"c", "d"}}), - }, - true, - [][]string{{"a", "b", "blah"}, {"c", "d", "blah"}}, - }, - } - - for _, s := range scenarios { - assert.EqualValues(t, s.expected, getDisplayStringArrays(s.input, s.isFocused)) - } -} - -// TestRenderDisplayableList is a function. -func TestRenderDisplayableList(t *testing.T) { - type scenario struct { - input []Displayable - config RenderListConfig - expectedString string - expectedErrorMessage string - } - - scenarios := []scenario{ - { - []Displayable{ - Displayable(&myDisplayable{[]string{}}), - Displayable(&myDisplayable{[]string{}}), - }, - RenderListConfig{}, - "\n", - "", - }, - { - []Displayable{ - Displayable(&myDisplayable{[]string{"aa", "b"}}), - Displayable(&myDisplayable{[]string{"c", "d"}}), - }, - RenderListConfig{}, - "aa b\nc d", - "", - }, - { - []Displayable{ - Displayable(&myDisplayable{[]string{"a"}}), - Displayable(&myDisplayable{[]string{"b", "c"}}), - }, - RenderListConfig{}, - "", - "Each item must return the same number of strings to display", - }, - { - []Displayable{ - Displayable(&myDisplayable{[]string{"a"}}), - Displayable(&myDisplayable{[]string{"b"}}), - }, - RenderListConfig{IsFocused: true}, - "a blah\nb blah", - "", - }, - } - - for _, s := range scenarios { - str, err := renderDisplayableList(s.input, s.config) - assert.EqualValues(t, s.expectedString, str) - if s.expectedErrorMessage != "" { - assert.EqualError(t, err, s.expectedErrorMessage) - } else { - assert.NoError(t, err) - } - } -} - -// TestRenderList is a function. -func TestRenderList(t *testing.T) { - type scenario struct { - input interface{} - options []func(*RenderListConfig) - expectedString string - expectedErrorMessage string - } - - scenarios := []scenario{ - { - []*myDisplayable{ - {[]string{"aa", "b"}}, - {[]string{"c", "d"}}, - }, - nil, - "aa b\nc d", - "", - }, - { - []*myStruct{ - {}, - {}, - }, - nil, - "", - "item does not implement the Displayable interface", - }, - { - &myStruct{}, - nil, - "", - "RenderList given a non-slice type", - }, - { - []*myDisplayable{ - {[]string{"a"}}, - }, - []func(*RenderListConfig){IsFocused(true)}, - "a blah", - "", - }, - } - - for _, s := range scenarios { - str, err := RenderList(s.input, s.options...) - assert.EqualValues(t, s.expectedString, str) - if s.expectedErrorMessage != "" { - assert.EqualError(t, err, s.expectedErrorMessage) - } else { - assert.NoError(t, err) - } - } -} - // TestGetPaddedDisplayStrings is a function. func TestGetPaddedDisplayStrings(t *testing.T) { type scenario struct { @@ -380,3 +221,39 @@ func TestGetPadWidths(t *testing.T) { assert.EqualValues(t, s.expected, getPadWidths(s.stringArrays)) } } + +func TestRenderTable(t *testing.T) { + type scenario struct { + input [][]string + expected string + expectedErr error + } + + scenarios := []scenario{ + { + input: [][]string{{"a", "b"}, {"c", "d"}}, + expected: "a b\nc d", + expectedErr: nil, + }, + { + input: [][]string{{"aaaa", "b"}, {"c", "d"}}, + expected: "aaaa b\nc d", + expectedErr: nil, + }, + { + input: [][]string{{"a"}, {"c", "d"}}, + expected: "", + expectedErr: errors.New("Each item must return the same number of strings to display"), + }, + } + + for _, s := range scenarios { + output, err := RenderTable(s.input) + assert.EqualValues(t, s.expected, output) + if s.expectedErr != nil { + assert.EqualError(t, err, s.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + } +}