convert menu panel to use new struct

pull/392/head
Jesse Duffield 2 years ago
parent 158ef68372
commit 60df208924

@ -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)

@ -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)
})
headers := [][]string{{"ID", "TAG", "SIZE", "COMMAND"}}
table := append(headers, tableBody...)
return utils.RenderList(layers, utils.WithHeader([]string{"ID", "TAG", "SIZE", "COMMAND"}))
return utils.RenderTable(table)
}
// RefreshImages returns a slice of docker images

@ -3,7 +3,3 @@ package commands
type Project struct {
Name string
}
func (self *Project) GetDisplayStrings(isFocused bool) []string {
return []string{self.Name}
}

@ -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)

@ -1,173 +1,165 @@
package commands
import (
"testing"
"github.com/docker/docker/api/types"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/stretchr/testify/assert"
)
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 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 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 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 TestSortContainers(t *testing.T) {
appConfig := NewDummyAppConfig()
appConfig.UserConfig = &config.UserConfig{
Gui: config.GuiConfig{
LegacySortContainers: false,
},
}
command := &DockerCommand{
Config: appConfig,
}
containers := sampleContainers(appConfig)
sorted := expectedPerStatusContainers(appConfig)
ct := command.sortedContainers(containers)
assert.Equal(t, len(ct), len(sorted))
for i := 0; i < len(ct); i++ {
assertEqualContainers(t, sorted[i], ct[i])
}
}
func TestLegacySortedContainers(t *testing.T) {
appConfig := NewDummyAppConfig()
appConfig.UserConfig = &config.UserConfig{
Gui: config.GuiConfig{
LegacySortContainers: true,
},
}
command := &DockerCommand{
Config: appConfig,
}
containers := sampleContainers(appConfig)
sorted := expectedLegacySortedContainers(appConfig)
ct := command.sortedContainers(containers)
for i := 0; i < len(ct); i++ {
assertEqualContainers(t, sorted[i], ct[i])
}
}
// 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 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 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 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 TestSortContainers(t *testing.T) {
// appConfig := NewDummyAppConfig()
// appConfig.UserConfig = &config.UserConfig{
// Gui: config.GuiConfig{
// LegacySortContainers: false,
// },
// }
// command := &DockerCommand{
// Config: appConfig,
// }
// containers := sampleContainers(appConfig)
// sorted := expectedPerStatusContainers(appConfig)
// ct := command.sortedContainers(containers)
// assert.Equal(t, len(ct), len(sorted))
// for i := 0; i < len(ct); i++ {
// assertEqualContainers(t, sorted[i], ct[i])
// }
// }
// func TestLegacySortedContainers(t *testing.T) {
// appConfig := NewDummyAppConfig()
// appConfig.UserConfig = &config.UserConfig{
// Gui: config.GuiConfig{
// LegacySortContainers: true,
// },
// }
// command := &DockerCommand{
// Config: appConfig,
// }
// containers := sampleContainers(appConfig)
// sorted := expectedLegacySortedContainers(appConfig)
// ct := command.sortedContainers(containers)
// for i := 0; i < len(ct); i++ {
// assertEqualContainers(t, sorted[i], ct[i])
// }
// }

@ -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{})

@ -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 {

@ -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,
}
onPress := func() error {
if command.InternalFunction != nil {
return command.InternalFunction()
}
handleMenuPress := func(index int) error {
option := options[index]
if !option.runCommand {
return nil
}
// 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)
}
if option.customCommand.InternalFunction != nil {
return option.customCommand.InternalFunction()
return gui.WithWaitingStatus(waitingStatus, func() error {
if err := gui.OSCommand.RunCommand(resolvedCommand); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
}
// 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 &MenuItem{
LabelColumns: []string{
command.Name,
utils.ColoredString(utils.WithShortSha(resolvedCommand), color.FgCyan),
},
OnPress: onPress,
}
})
return gui.WithWaitingStatus(waitingStatus, func() error {
if err := gui.OSCommand.RunCommand(option.command); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
}
return gui.createMenu(title, options, len(options), handleMenuPress)
return gui.Menu(CreateMenuOptions{
Title: title,
Items: menuItems,
})
}
func (gui *Gui) createCustomCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject) error {

@ -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]])
})

@ -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-<service id>-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 {

@ -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 {

@ -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,

@ -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()
}
}

@ -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()

@ -1,112 +1,139 @@
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
func (gui *Gui) handleMenuNextLine(g *gocui.Gui, v *gocui.View) error {
panelState := gui.State.Panels.Menu
gui.changeSelectedLine(&panelState.SelectedLine, v.LinesHeight(), false)
OnPress func() error
return gui.handleMenuSelect(g, v)
// Only applies when Label is used
OpensMenu 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
type CreateMenuOptions struct {
Title string
Items []*MenuItem
HideCancel bool
}
if err := gui.handleClick(v, itemCount, selectedLine, handleSelect); err != nil {
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
},
// 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)
}
// specific functions
func (gui *Gui) renderMenuOptions() error {
optionsMap := map[string]string{
"esc/q": gui.Tr.Close,
"↑ ↓": gui.Tr.Navigate,
"enter": gui.Tr.Execute,
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
},
})
}
return gui.renderOptionsMap(optionsMap)
}
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
maxColumnSize := 1
for _, item := range opts.Items {
if item.LabelColumns == nil {
item.LabelColumns = []string{item.Label}
}
}
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
if item.OpensMenu {
item.LabelColumns[0] = utils.OpensMenuStyle(item.LabelColumns[0])
}
maxColumnSize = utils.Max(maxColumnSize, len(item.LabelColumns))
}
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
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))...)
}
}
if err := handlePress(selectedLine); err != nil {
return err
}
gui.Panels.Menu.SetItems(opts.Items)
gui.Panels.Menu.setSelectedLineIdx(0)
return nil
if err := gui.Panels.Menu.RerenderList(); err != nil {
return err
}
gui.State.Panels.Menu.OnPress = wrappedHandlePress
gui.Views.Menu.Title = opts.Title
gui.Views.Menu.Visible = true
for _, key := range []gocui.Key{gocui.KeySpace, gocui.KeyEnter, 'y'} {
_ = gui.g.DeleteKeybinding("menu", key, gocui.ModNone)
return gui.switchFocus(gui.Views.Menu)
}
if err := gui.g.SetKeybinding("menu", key, gocui.ModNone, wrappedHandlePress); err != nil {
return err
}
// specific functions
func (gui *Gui) renderMenuOptions() error {
optionsMap := map[string]string{
"esc/q": gui.Tr.Close,
"↑ ↓": gui.Tr.Navigate,
"enter": gui.Tr.Execute,
}
return gui.renderOptionsMap(optionsMap)
}
gui.g.Update(func(g *gocui.Gui) error {
menuView.Visible = true
return gui.switchFocus(menuView)
})
return nil
func (gui *Gui) handleMenuClose(g *gocui.Gui, v *gocui.View) error {
gui.Views.Menu.Visible = false
return gui.returnFocus()
}

@ -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,
})
}

@ -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}
},
}
}

@ -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 {

@ -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)
}

@ -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 {

@ -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)
}

@ -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)
}
}
}

Loading…
Cancel
Save