convert menu panel to use new struct

This commit is contained in:
Jesse Duffield 2022-10-22 19:37:50 -07:00
parent 158ef68372
commit 60df208924
22 changed files with 623 additions and 849 deletions

View file

@ -49,14 +49,9 @@ type Container struct {
StatsMutex sync.Mutex StatsMutex sync.Mutex
} }
// GetDisplayStrings returns the dispaly string of Container // TODO: move this stuff into a presentation layer
func (c *Container) GetDisplayStrings(isFocused bool) []string {
image := strings.TrimPrefix(c.Container.Image, "sha256:")
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 { portStrings := lo.Map(c.Container.Ports, func(port types.Port, _ int) string {
if port.PublicPort == 0 { if port.PublicPort == 0 {
return fmt.Sprintf("%d/%s", port.PrivatePort, port.Type) return fmt.Sprintf("%d/%s", port.PrivatePort, port.Type)

View file

@ -5,6 +5,7 @@ import (
"strings" "strings"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/samber/lo"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
@ -26,11 +27,6 @@ type Image struct {
DockerCommand LimitedDockerCommand 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 // Remove removes the image
func (i *Image) Remove(options types.ImageRemoveOptions) error { func (i *Image) Remove(options types.ImageRemoveOptions) error {
if _, err := i.Client.ImageRemove(context.Background(), i.ID, options); err != nil { 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 return nil
} }
// Layer is a layer in an image's history func getHistoryResponseItemDisplayStrings(layer image.HistoryResponseItem) []string {
type Layer struct {
image.HistoryResponseItem
}
// GetDisplayStrings returns the array of strings describing the layer
func (l *Layer) GetDisplayStrings(isFocused bool) []string {
tag := "" tag := ""
if len(l.Tags) > 0 { if len(layer.Tags) > 0 {
tag = l.Tags[0] tag = layer.Tags[0]
} }
id := strings.TrimPrefix(l.ID, "sha256:") id := strings.TrimPrefix(layer.ID, "sha256:")
if len(id) > 10 { if len(id) > 10 {
id = id[0:10] id = id[0:10]
} }
@ -62,16 +52,16 @@ func (l *Layer) GetDisplayStrings(isFocused bool) []string {
} }
dockerFileCommandPrefix := "/bin/sh -c #(nop) " dockerFileCommandPrefix := "/bin/sh -c #(nop) "
createdBy := l.CreatedBy createdBy := layer.CreatedBy
if strings.Contains(l.CreatedBy, dockerFileCommandPrefix) { if strings.Contains(layer.CreatedBy, dockerFileCommandPrefix) {
createdBy = strings.Trim(strings.TrimPrefix(l.CreatedBy, dockerFileCommandPrefix), " ") createdBy = strings.Trim(strings.TrimPrefix(layer.CreatedBy, dockerFileCommandPrefix), " ")
split := strings.Split(createdBy, " ") split := strings.Split(createdBy, " ")
createdBy = utils.ColoredString(split[0], color.FgYellow) + " " + strings.Join(split[1:], " ") createdBy = utils.ColoredString(split[0], color.FgYellow) + " " + strings.Join(split[1:], " ")
} }
createdBy = strings.Replace(createdBy, "\t", " ", -1) createdBy = strings.Replace(createdBy, "\t", " ", -1)
size := utils.FormatBinaryBytes(int(l.Size)) size := utils.FormatBinaryBytes(int(layer.Size))
sizeColor := color.FgWhite sizeColor := color.FgWhite
if size == "0B" { if size == "0B" {
sizeColor = color.FgBlue sizeColor = color.FgBlue
@ -92,12 +82,14 @@ func (i *Image) RenderHistory() (string, error) {
return "", err return "", err
} }
layers := make([]*Layer, len(history)) tableBody := lo.Map(history, func(layer image.HistoryResponseItem, _ int) []string {
for i, layer := range history { return getHistoryResponseItemDisplayStrings(layer)
layers[i] = &Layer{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 // RefreshImages returns a slice of docker images

View file

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

View file

@ -6,7 +6,6 @@ import (
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/fatih/color"
"github.com/jesseduffield/lazydocker/pkg/utils" "github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -21,16 +20,6 @@ type Service struct {
DockerCommand LimitedDockerCommand 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 // Remove removes the service's containers
func (s *Service) Remove(options types.ContainerRemoveOptions) error { func (s *Service) Remove(options types.ContainerRemoveOptions) error {
return s.Container.Remove(options) return s.Container.Remove(options)

View file

@ -1,173 +1,165 @@
package commands package commands
import ( // func sampleContainers(userConfig *config.AppConfig) []*Container {
"testing" // 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" // func expectedPerStatusContainers(appConfig *config.AppConfig) []*Container {
"github.com/jesseduffield/lazydocker/pkg/config" // return []*Container{
"github.com/stretchr/testify/assert" // {
) // 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 { // func expectedLegacySortedContainers(appConfig *config.AppConfig) []*Container {
return []*Container{ // return []*Container{
{ // {
ID: "1", // ID: "1",
Name: "1", // Name: "1",
Container: types.Container{ // Container: types.Container{
State: "exited", // State: "exited",
}, // },
Config: userConfig, // Config: appConfig,
}, // },
{ // {
ID: "2", // ID: "2",
Name: "2", // Name: "2",
Container: types.Container{ // Container: types.Container{
State: "running", // State: "running",
}, // },
Config: userConfig, // Config: appConfig,
}, // },
{ // {
ID: "3", // ID: "3",
Name: "3", // Name: "3",
Container: types.Container{ // Container: types.Container{
State: "running", // State: "running",
}, // },
Config: userConfig, // Config: appConfig,
}, // },
{ // {
ID: "4", // ID: "4",
Name: "4", // Name: "4",
Container: types.Container{ // Container: types.Container{
State: "created", // State: "created",
}, // },
Config: userConfig, // Config: appConfig,
}, // },
} // }
} // }
func expectedPerStatusContainers(appConfig *config.AppConfig) []*Container { // func assertEqualContainers(t *testing.T, left *Container, right *Container) {
return []*Container{ // t.Helper()
{ // assert.Equal(t, left.Container.State, right.Container.State)
ID: "2", // assert.Equal(t, left.Container.ID, right.Container.ID)
Name: "2", // assert.Equal(t, left.Name, right.Name)
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 { // func TestSortContainers(t *testing.T) {
return []*Container{ // appConfig := NewDummyAppConfig()
{ // appConfig.UserConfig = &config.UserConfig{
ID: "1", // Gui: config.GuiConfig{
Name: "1", // LegacySortContainers: false,
Container: types.Container{ // },
State: "exited", // }
}, // command := &DockerCommand{
Config: appConfig, // 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) { // containers := sampleContainers(appConfig)
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) { // sorted := expectedPerStatusContainers(appConfig)
appConfig := NewDummyAppConfig()
appConfig.UserConfig = &config.UserConfig{
Gui: config.GuiConfig{
LegacySortContainers: false,
},
}
command := &DockerCommand{
Config: 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++ { // containers := sampleContainers(appConfig)
assertEqualContainers(t, sorted[i], ct[i])
}
}
func TestLegacySortedContainers(t *testing.T) { // sorted := expectedLegacySortedContainers(appConfig)
appConfig := NewDummyAppConfig()
appConfig.UserConfig = &config.UserConfig{
Gui: config.GuiConfig{
LegacySortContainers: true,
},
}
command := &DockerCommand{
Config: appConfig,
}
containers := sampleContainers(appConfig) // ct := command.sortedContainers(containers)
sorted := expectedLegacySortedContainers(appConfig) // for i := 0; i < len(ct); i++ {
// assertEqualContainers(t, sorted[i], ct[i])
ct := command.sortedContainers(containers) // }
// }
for i := 0; i < len(ct); i++ {
assertEqualContainers(t, sorted[i], ct[i])
}
}

View file

@ -19,11 +19,6 @@ type Volume struct {
DockerCommand LimitedDockerCommand 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 // RefreshVolumes gets the volumes and stores them
func (c *DockerCommand) RefreshVolumes() ([]*Volume, error) { func (c *DockerCommand) RefreshVolumes() ([]*Volume, error) {
result, err := c.Client.VolumeList(context.Background(), filters.Args{}) result, err := c.Client.VolumeList(context.Background(), filters.Args{})

View file

@ -39,9 +39,9 @@ func (gui *Gui) getContainersPanel() *SideListPanel[*commands.Container] {
list: NewFilteredList[*commands.Container](), list: NewFilteredList[*commands.Container](),
view: gui.Views.Containers, view: gui.Views.Containers,
}, },
contextIdx: 0, contextIdx: 0,
noItemsMessge: gui.Tr.NoContainers, noItemsMessage: gui.Tr.NoContainers,
gui: gui.intoInterface(), gui: gui.intoInterface(),
getContexts: func() []ContextConfig[*commands.Container] { getContexts: func() []ContextConfig[*commands.Container] {
return []ContextConfig[*commands.Container]{ return []ContextConfig[*commands.Container]{
{ {
@ -107,6 +107,18 @@ func (gui *Gui) getContainersPanel() *SideListPanel[*commands.Container] {
return true 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 { func (gui *Gui) refreshContainersAndServices() error {
containersView := gui.getContainersView() if gui.Views.Containers == nil {
if containersView == nil {
// if the containersView hasn't been instantiated yet we just return // if the containersView hasn't been instantiated yet we just return
return nil return nil
} }
@ -300,11 +311,6 @@ type removeContainerOption struct {
configOptions types.ContainerRemoveOptions 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 { func (gui *Gui) handleHideStoppedContainers(g *gocui.Gui, v *gocui.View) error {
gui.State.ShowExitedContainers = !gui.State.ShowExitedContainers gui.State.ShowExitedContainers = !gui.State.ShowExitedContainers
@ -317,28 +323,7 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error {
return nil return nil
} }
options := []*removeContainerOption{ handleMenuPress := func(configOptions types.ContainerRemoveOptions) error {
{
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
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
if err := container.Remove(configOptions); err != nil { if err := container.Remove(configOptions); err != nil {
if commands.HasErrorCode(err, commands.MustStopContainer) { 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 { func (gui *Gui) PauseContainer(container *commands.Container) error {

View file

@ -5,66 +5,45 @@ import (
"github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils" "github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
) )
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 { func (gui *Gui) createCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject, title string, waitingStatus string) error {
options := make([]*customCommandOption, len(customCommands)+1) menuItems := lo.Map(customCommands, func(command config.CustomCommand, _ int) *MenuItem {
for i, command := range customCommands {
resolvedCommand := utils.ApplyTemplate(command.Command, commandObject) resolvedCommand := utils.ApplyTemplate(command.Command, commandObject)
options[i] = &customCommandOption{ onPress := func() error {
customCommand: command, if command.InternalFunction != nil {
description: utils.WithShortSha(resolvedCommand), return command.InternalFunction()
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())
} }
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 { func (gui *Gui) createCustomCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject) error {

View file

@ -44,6 +44,10 @@ func (self *FilteredList[T]) Sort(less func(T, T) bool) {
self.mutex.Lock() self.mutex.Lock()
defer self.mutex.Unlock() defer self.mutex.Unlock()
if less == nil {
return
}
sort.Slice(self.indices, func(i, j int) bool { sort.Slice(self.indices, func(i, j int) bool {
return less(self.allItems[self.indices[i]], self.allItems[self.indices[j]]) return less(self.allItems[self.indices[i]], self.allItems[self.indices[j]])
}) })

View file

@ -53,6 +53,7 @@ type Panels struct {
Containers *SideListPanel[*commands.Container] Containers *SideListPanel[*commands.Container]
Images *SideListPanel[*commands.Image] Images *SideListPanel[*commands.Image]
Volumes *SideListPanel[*commands.Volume] Volumes *SideListPanel[*commands.Volume]
Menu *SideListPanel[*MenuItem]
} }
type Mutexes struct { type Mutexes struct {
@ -60,28 +61,16 @@ type Mutexes struct {
ViewStackMutex sync.Mutex 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 { 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 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 ObjectKey string
} }
type panelStates struct { type panelStates struct {
Menu *menuPanelState Main *mainPanelState
Main *mainPanelState
Project *projectState
} }
type guiState struct { 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) // the names of views in the current focus stack (last item is the current view)
ViewStack []string ViewStack []string
Platform commands.Platform Platform commands.Platform
@ -95,16 +84,6 @@ type guiState struct {
ScreenMode WindowMaximisation ScreenMode WindowMaximisation
Searching searchingState Searching searchingState
Lists Lists
}
// these are the items we display, after filtering is applied.
type Lists struct {
Containers *FilteredList[*commands.Container]
Services *FilteredList[*commands.Service]
Images *FilteredList[*commands.Image]
Volumes *FilteredList[*commands.Volume]
} }
type searchingState struct { type searchingState struct {
@ -130,25 +109,13 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand
initialState := guiState{ initialState := guiState{
Platform: *oSCommand.Platform, Platform: *oSCommand.Platform,
Panels: &panelStates{ Panels: &panelStates{
Menu: &menuPanelState{SelectedLine: 0},
Main: &mainPanelState{ Main: &mainPanelState{
ObjectKey: "", ObjectKey: "",
}, },
Project: &projectState{ContextIndex: 0},
}, },
ViewStack: []string{}, ViewStack: []string{},
Lists: Lists{
Containers: NewFilteredList[*commands.Container](),
Services: NewFilteredList[*commands.Service](),
Images: NewFilteredList[*commands.Image](),
Volumes: NewFilteredList[*commands.Volume](),
},
ShowExitedContainers: true,
}
cyclableViews := []string{"project", "containers", "images", "volumes"} ShowExitedContainers: true,
if dockerCommand.InDockerComposeProject {
cyclableViews = []string{"project", "services", "containers", "images", "volumes"}
} }
gui := &Gui{ gui := &Gui{
@ -162,9 +129,10 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand
statusManager: &statusManager{}, statusManager: &statusManager{},
T: tasks.NewTaskManager(log, tr), T: tasks.NewTaskManager(log, tr),
ErrorChan: errorChan, ErrorChan: errorChan,
CyclableViews: cyclableViews,
} }
gui.CyclableViews = gui.sideViewNames()
return gui, nil return gui, nil
} }
@ -245,6 +213,7 @@ func (gui *Gui) Run() error {
Containers: gui.getContainersPanel(), Containers: gui.getContainersPanel(),
Images: gui.getImagesPanel(), Images: gui.getImagesPanel(),
Volumes: gui.getVolumesPanel(), Volumes: gui.getVolumesPanel(),
Menu: gui.getMenuPanel(),
} }
if err = gui.keybindings(g); err != nil { if err = gui.keybindings(g); err != nil {

View file

@ -11,6 +11,7 @@ import (
"github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils" "github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
) )
func (gui *Gui) getImagesPanel() *SideListPanel[*commands.Image] { func (gui *Gui) getImagesPanel() *SideListPanel[*commands.Image] {
@ -22,9 +23,9 @@ func (gui *Gui) getImagesPanel() *SideListPanel[*commands.Image] {
list: NewFilteredList[*commands.Image](), list: NewFilteredList[*commands.Image](),
view: gui.Views.Images, view: gui.Views.Images,
}, },
contextIdx: 0, contextIdx: 0,
noItemsMessge: gui.Tr.NoImages, noItemsMessage: gui.Tr.NoImages,
gui: gui.intoInterface(), gui: gui.intoInterface(),
getContexts: func() []ContextConfig[*commands.Image] { getContexts: func() []ContextConfig[*commands.Image] {
return []ContextConfig[*commands.Image]{ return []ContextConfig[*commands.Image]{
{ {
@ -53,6 +54,9 @@ func (gui *Gui) getImagesPanel() *SideListPanel[*commands.Image] {
return a.Name < b.Name 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) 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 { 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() image, err := gui.Panels.Images.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
@ -139,45 +137,44 @@ func (gui *Gui) handleImagesRemoveMenu(g *gocui.Gui, v *gocui.View) error {
description: gui.Tr.Remove, description: gui.Tr.Remove,
command: "docker image rm " + shortSha, command: "docker image rm " + shortSha,
configOptions: types.ImageRemoveOptions{PruneChildren: true, Force: false}, configOptions: types.ImageRemoveOptions{PruneChildren: true, Force: false},
runCommand: true,
}, },
{ {
description: gui.Tr.RemoveWithoutPrune, description: gui.Tr.RemoveWithoutPrune,
command: "docker image rm --no-prune " + shortSha, command: "docker image rm --no-prune " + shortSha,
configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: false}, configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: false},
runCommand: true,
}, },
{ {
description: gui.Tr.RemoveWithForce, description: gui.Tr.RemoveWithForce,
command: "docker image rm --force " + shortSha, command: "docker image rm --force " + shortSha,
configOptions: types.ImageRemoveOptions{PruneChildren: true, Force: true}, configOptions: types.ImageRemoveOptions{PruneChildren: true, Force: true},
runCommand: true,
}, },
{ {
description: gui.Tr.RemoveWithoutPruneWithForce, description: gui.Tr.RemoveWithoutPruneWithForce,
command: "docker image rm --no-prune --force " + shortSha, command: "docker image rm --no-prune --force " + shortSha,
configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: true}, configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: true},
runCommand: true,
},
{
description: gui.Tr.Cancel,
runCommand: false,
}, },
} }
handleMenuPress := func(index int) error { menuItems := lo.Map(options, func(option *removeImageOption, _ int) *MenuItem {
if !options[index].runCommand { return &MenuItem{
return nil LabelColumns: []string{
} option.description,
configOptions := options[index].configOptions color.New(color.FgRed).Sprint(option.command),
if cerr := image.Remove(configOptions); cerr != nil { },
return gui.createErrorPanel(cerr.Error()) 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 { func (gui *Gui) handlePruneImages() error {

View file

@ -17,11 +17,6 @@ type Binding struct {
Description string 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. // GetKey is a function.
func (b *Binding) GetKey() string { func (b *Binding) GetKey() string {
key := 0 key := 0
@ -186,6 +181,24 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Modifier: gocui.ModNone, Modifier: gocui.ModNone,
Handler: gui.handleMenuClose, 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", ViewName: "information",
Key: gocui.MouseLeft, Key: gocui.MouseLeft,
@ -561,7 +574,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
} }
// TODO: add more views here // 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{ bindings = append(bindings, []*Binding{
{ViewName: viewName, Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: gui.previousView}, {ViewName: viewName, Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: gui.previousView},
{ViewName: viewName, Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, Handler: gui.nextView}, {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 onKeyDownPress func(*gocui.Gui, *gocui.View) error
onClick 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)}, "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)}, "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)}, "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{ bindings = append(bindings, &Binding{
ViewName: sidePanel.View().Name(), ViewName: sidePanel.View().Name(),
Key: gocui.KeyEnter, Key: gocui.KeyEnter,

View file

@ -115,33 +115,14 @@ func (gui *Gui) layout(g *gocui.Gui) error {
return gui.resizeCurrentPopupPanel(g) return gui.resizeCurrentPopupPanel(g)
} }
type listViewState struct {
selectedLine int
lineCount int
}
func (gui *Gui) focusPointInView(view *gocui.View) { func (gui *Gui) focusPointInView(view *gocui.View) {
if view == nil { if view == nil {
return return
} }
listViews := map[string]listViewState{ currentPanel, ok := gui.currentListPanel()
"menu": {selectedLine: gui.State.Panels.Menu.SelectedLine, lineCount: gui.State.MenuItemCount}, if ok {
} currentPanel.Refocus()
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()
} }
} }

View file

@ -57,7 +57,9 @@ type SideListPanel[T comparable] struct {
ListPanel[T] 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 gui IGui
@ -67,12 +69,17 @@ type SideListPanel[T comparable] struct {
// this filter is applied on top of additional default filters // this filter is applied on top of additional default filters
filter func(T) bool filter func(T) bool
sort func(a, b T) bool sort func(a, b T) bool
onClick func(T) error
getDisplayStrings func(T) []string
} }
type ISideListPanel interface { type ISideListPanel interface {
SetContextIndex(int) SetContextIndex(int)
HandleSelect() error HandleSelect() error
View() *gocui.View View() *gocui.View
Refocus()
} }
var _ ISideListPanel = &SideListPanel[int]{} var _ ISideListPanel = &SideListPanel[int]{}
@ -106,7 +113,18 @@ func (self *SideListPanel[T]) OnClick() error {
handleSelect := self.HandleSelect handleSelect := self.HandleSelect
selectedLine := &self.selectedIdx 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 { func (self *SideListPanel[T]) View() *gocui.View {
@ -116,17 +134,29 @@ func (self *SideListPanel[T]) View() *gocui.View {
func (self *SideListPanel[T]) HandleSelect() error { func (self *SideListPanel[T]) HandleSelect() error {
item, err := self.GetSelectedItem() item, err := self.GetSelectedItem()
if err != nil { if err != nil {
if err.Error() != self.noItemsMessge { if err.Error() != self.noItemsMessage {
return err return err
} }
return self.gui.RenderStringMain(self.noItemsMessge) if self.noItemsMessage != "" {
return self.gui.RenderStringMain(self.noItemsMessage)
}
return nil
} }
self.Refocus() self.Refocus()
return self.renderContext(item)
}
func (self *SideListPanel[T]) renderContext(item T) error {
contexts := self.getContexts() contexts := self.getContexts()
if len(contexts) == 0 {
return nil
}
key := self.contextKeyPrefix + "-" + self.getContextCacheKey(item) + "-" + contexts[self.contextIdx].key key := self.contextKeyPrefix + "-" + self.getContextCacheKey(item) + "-" + contexts[self.contextIdx].key
if !self.gui.ShouldRefresh(key) { if !self.gui.ShouldRefresh(key) {
return nil return nil
@ -136,7 +166,6 @@ func (self *SideListPanel[T]) HandleSelect() error {
mainView.Tabs = self.GetContextTitles() mainView.Tabs = self.GetContextTitles()
mainView.TabIndex = self.contextIdx mainView.TabIndex = self.contextIdx
// now I have an item. What do I do with it?
return contexts[self.contextIdx].render(item) return contexts[self.contextIdx].render(item)
} }
@ -152,36 +181,24 @@ func (self *SideListPanel[T]) GetSelectedItem() (T, error) {
item, ok := self.list.TryGet(self.selectedIdx) item, ok := self.list.TryGet(self.selectedIdx)
if !ok { if !ok {
// could probably have a better error here // could probably have a better error here
return zero, errors.New(self.noItemsMessge) return zero, errors.New(self.noItemsMessage)
} }
return item, nil return item, nil
} }
func (self *SideListPanel[T]) OnNextLine() error { func (self *SideListPanel[T]) OnNextLine() error {
if self.ignoreKeypress() {
return nil
}
self.SelectNextLine() self.SelectNextLine()
return self.HandleSelect() return self.HandleSelect()
} }
func (self *SideListPanel[T]) OnPrevLine() error { func (self *SideListPanel[T]) OnPrevLine() error {
if self.ignoreKeypress() {
return nil
}
self.SelectPrevLine() self.SelectPrevLine()
return self.HandleSelect() return self.HandleSelect()
} }
func (self *SideListPanel[T]) ignoreKeypress() bool {
return self.gui.PopupPanelFocused() || self.gui.CurrentView() != self.view
}
func (self *SideListPanel[T]) OnNextContext() error { func (self *SideListPanel[T]) OnNextContext() error {
contexts := self.getContexts() contexts := self.getContexts()
@ -250,12 +267,14 @@ func (self *SideListPanel[T]) RerenderList() error {
self.gui.Update(func() error { self.gui.Update(func() error {
self.view.Clear() self.view.Clear()
isFocused := self.gui.CurrentView() == self.view table := lo.Map(self.list.GetItems(), func(item T, index int) []string {
list, err := utils.RenderList(self.list.GetItems(), utils.IsFocused(isFocused)) return self.getDisplayStrings(item)
})
renderedTable, err := utils.RenderTable(table)
if err != nil { if err != nil {
return err return err
} }
fmt.Fprint(self.view, list) fmt.Fprint(self.view, renderedTable)
if self.view == self.gui.CurrentView() { if self.view == self.gui.CurrentView() {
return self.HandleSelect() return self.HandleSelect()

View file

@ -1,43 +1,125 @@
package gui package gui
import ( import (
"fmt"
"github.com/jesseduffield/gocui" "github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/utils" "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 { // alternative to Label. Allows specifying columns which will be auto-aligned
gui.focusY(gui.State.Panels.Menu.SelectedLine, gui.State.MenuItemCount, v) LabelColumns []string
return nil
OnPress func() error
// Only applies when Label is used
OpensMenu bool
} }
func (gui *Gui) handleMenuNextLine(g *gocui.Gui, v *gocui.View) error { type CreateMenuOptions struct {
panelState := gui.State.Panels.Menu Title string
gui.changeSelectedLine(&panelState.SelectedLine, v.LinesHeight(), false) Items []*MenuItem
HideCancel bool
return gui.handleMenuSelect(g, v)
} }
func (gui *Gui) handleMenuClick(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) getMenuPanel() *SideListPanel[*MenuItem] {
itemCount := gui.State.MenuItemCount return &SideListPanel[*MenuItem]{
handleSelect := gui.handleMenuSelect ListPanel: ListPanel[*MenuItem]{
selectedLine := &gui.State.Panels.Menu.SelectedLine 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 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 { func (gui *Gui) handleMenuPress() error {
panelState := gui.State.Panels.Menu selectedMenuItem, err := gui.Panels.Menu.GetSelectedItem()
gui.changeSelectedLine(&panelState.SelectedLine, v.LinesHeight(), true) 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 // specific functions
@ -52,61 +134,6 @@ func (gui *Gui) renderMenuOptions() error {
} }
func (gui *Gui) handleMenuClose(g *gocui.Gui, v *gocui.View) 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 gui.Views.Menu.Visible = false
return gui.returnFocus() 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
}

View file

@ -1,7 +1,7 @@
package gui package gui
import ( import (
"github.com/go-errors/errors" "github.com/samber/lo"
"github.com/jesseduffield/gocui" "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 { 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 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 { return binding.Handler(g, v)
if bindings[index].Key == nil { },
return nil
}
if index >= len(bindings) {
return errors.New("Index is greater than size of bindings")
} }
})
return bindings[index].Handler(g, v) return gui.Menu(CreateMenuOptions{
} Title: gui.Tr.MenuTitle,
Items: menuItems,
return gui.createMenu(gui.Tr.MenuTitle, bindings, len(bindings), handleMenuPress) HideCancel: true,
})
} }

View file

@ -22,9 +22,9 @@ func (gui *Gui) getProjectPanel() *SideListPanel[*commands.Project] {
list: NewFilteredList[*commands.Project](), list: NewFilteredList[*commands.Project](),
view: gui.Views.Project, view: gui.Views.Project,
}, },
contextIdx: 0, contextIdx: 0,
noItemsMessge: "no projects", // we don't expect to ever actually see this noItemsMessage: "",
gui: gui.intoInterface(), gui: gui.intoInterface(),
getContexts: func() []ContextConfig[*commands.Project] { getContexts: func() []ContextConfig[*commands.Project] {
if gui.DockerCommand.InDockerComposeProject { if gui.DockerCommand.InDockerComposeProject {
return []ContextConfig[*commands.Project]{ return []ContextConfig[*commands.Project]{
@ -63,6 +63,9 @@ func (gui *Gui) getProjectPanel() *SideListPanel[*commands.Project] {
sort: func(a *commands.Project, b *commands.Project) bool { sort: func(a *commands.Project, b *commands.Project) bool {
return false return false
}, },
getDisplayStrings: func(project *commands.Project) []string {
return []string{project.Name}
},
} }
} }

View file

@ -9,6 +9,7 @@ import (
"github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils" "github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
) )
func (gui *Gui) getServicesPanel() *SideListPanel[*commands.Service] { func (gui *Gui) getServicesPanel() *SideListPanel[*commands.Service] {
@ -20,8 +21,8 @@ func (gui *Gui) getServicesPanel() *SideListPanel[*commands.Service] {
}, },
contextIdx: 0, contextIdx: 0,
// TODO: i18n // TODO: i18n
noItemsMessge: "no service selected", noItemsMessage: "no service selected",
gui: gui.intoInterface(), gui: gui.intoInterface(),
getContexts: func() []ContextConfig[*commands.Service] { getContexts: func() []ContextConfig[*commands.Service] {
return []ContextConfig[*commands.Service]{ return []ContextConfig[*commands.Service]{
{ {
@ -73,6 +74,26 @@ func (gui *Gui) getServicesPanel() *SideListPanel[*commands.Service] {
return a.Name < b.Name 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 { type commandOption struct {
description string description string
command string command string
f func() error onPress func() error
} }
// GetDisplayStrings is a function. func (r *commandOption) getDisplayStrings() []string {
func (r *commandOption) GetDisplayStrings(isFocused bool) []string {
return []string{r.description, color.New(color.FgCyan).Sprint(r.command)} 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, description: gui.Tr.RemoveWithVolumes,
command: fmt.Sprintf("%s rm --stop --force -v %s", composeCommand, service.Name), 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 { 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, description: gui.Tr.Down,
command: downCommand, command: downCommand,
f: func() error { onPress: func() error {
return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error { return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error {
if err := gui.OSCommand.RunCommand(downCommand); err != nil { if err := gui.OSCommand.RunCommand(downCommand); err != nil {
return gui.createErrorPanel(err.Error()) return gui.createErrorPanel(err.Error())
@ -310,7 +345,7 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error {
{ {
description: gui.Tr.DownWithVolumes, description: gui.Tr.DownWithVolumes,
command: downWithVolumesCommand, command: downWithVolumesCommand,
f: func() error { onPress: func() error {
return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error { return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error {
if err := gui.OSCommand.RunCommand(downWithVolumesCommand); err != nil { if err := gui.OSCommand.RunCommand(downWithVolumesCommand); err != nil {
return gui.createErrorPanel(err.Error()) 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 { 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.Config.UserConfig.CommandTemplates.RestartService,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}), gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}),
), ),
f: func() error { onPress: func() error {
return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error { return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error {
if err := service.Restart(); err != nil { if err := service.Restart(); err != nil {
return gui.createErrorPanel(err.Error()) 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.Config.UserConfig.CommandTemplates.RecreateService,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}), gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}),
), ),
f: func() error { onPress: func() error {
return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error { return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error {
if err := gui.OSCommand.RunCommand(recreateCommand); err != nil { if err := gui.OSCommand.RunCommand(recreateCommand); err != nil {
return gui.createErrorPanel(err.Error()) 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.Config.UserConfig.CommandTemplates.RebuildService,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}), gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}),
), ),
f: func() error { onPress: func() error {
return gui.runSubprocess(gui.OSCommand.RunCustomCommand(rebuildCommand)) 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() } menuItems := lo.Map(options, func(option *commandOption, _ int) *MenuItem {
return &MenuItem{
return gui.createMenu("", options, len(options), handleMenuPress) LabelColumns: option.getDisplayStrings(),
} OnPress: option.onPress,
func (gui *Gui) createServiceCommandMenu(options []*commandOption, status string) error {
handleMenuPress := func(index int) error {
if options[index].command == "" {
return nil
} }
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.Menu(CreateMenuOptions{
}) Title: "",
} Items: menuItems,
})
return gui.createMenu("", options, len(options), handleMenuPress)
} }
func (gui *Gui) handleServicesCustomCommand(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleServicesCustomCommand(g *gocui.Gui, v *gocui.View) error {

View file

@ -71,14 +71,12 @@ func (gui *Gui) newLineFocused(v *gocui.View) error {
return nil return nil
} }
currentSidePanel, ok := gui.currentSidePanel() currentListPanel, ok := gui.currentListPanel()
if ok { if ok {
return currentSidePanel.HandleSelect() return currentListPanel.HandleSelect()
} }
switch v.Name() { switch v.Name() {
case "menu":
return gui.handleMenuSelect(gui.g, v)
case "confirmation": case "confirmation":
return nil return nil
case "main": case "main":
@ -136,7 +134,7 @@ func (gui *Gui) returnFocus() error {
func (gui *Gui) pushView(name string) { func (gui *Gui) pushView(name string) {
// No matter what view we're pushing, we first remove all popup panels from the stack // 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 { 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 // 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)) 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 { func (gui *Gui) getMainView() *gocui.View {
return gui.Views.Main return gui.Views.Main
} }
@ -391,7 +364,7 @@ func (gui *Gui) renderPanelOptions() error {
} }
func (gui *Gui) isPopupPanel(viewName string) bool { func (gui *Gui) isPopupPanel(viewName string) bool {
return viewName == "confirmation" || viewName == "menu" return lo.Contains(gui.popupViewNames(), viewName)
} }
func (gui *Gui) popupPanelFocused() bool { func (gui *Gui) popupPanelFocused() bool {
@ -500,7 +473,7 @@ func (gui *Gui) CurrentView() *gocui.View {
func (gui *Gui) currentSidePanel() (ISideListPanel, bool) { func (gui *Gui) currentSidePanel() (ISideListPanel, bool) {
viewName := gui.currentViewName() viewName := gui.currentViewName()
for _, sidePanel := range gui.allSidepanels() { for _, sidePanel := range gui.allSidePanels() {
if sidePanel.View().Name() == viewName { if sidePanel.View().Name() == viewName {
return sidePanel, true return sidePanel, true
} }
@ -509,7 +482,19 @@ func (gui *Gui) currentSidePanel() (ISideListPanel, bool) {
return nil, false 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{ return []ISideListPanel{
gui.Panels.Projects, gui.Panels.Projects,
gui.Panels.Services, gui.Panels.Services,
@ -518,3 +503,7 @@ func (gui *Gui) allSidepanels() []ISideListPanel {
gui.Panels.Volumes, gui.Panels.Volumes,
} }
} }
func (gui *Gui) allListPanels() []ISideListPanel {
return append(gui.allSidePanels(), gui.Panels.Menu)
}

View file

@ -8,6 +8,7 @@ import (
"github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils" "github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
) )
func (gui *Gui) getVolumesPanel() *SideListPanel[*commands.Volume] { func (gui *Gui) getVolumesPanel() *SideListPanel[*commands.Volume] {
@ -17,9 +18,9 @@ func (gui *Gui) getVolumesPanel() *SideListPanel[*commands.Volume] {
list: NewFilteredList[*commands.Volume](), list: NewFilteredList[*commands.Volume](),
view: gui.Views.Volumes, view: gui.Views.Volumes,
}, },
contextIdx: 0, contextIdx: 0,
noItemsMessge: gui.Tr.NoVolumes, noItemsMessage: gui.Tr.NoVolumes,
gui: gui.intoInterface(), gui: gui.intoInterface(),
getContexts: func() []ContextConfig[*commands.Volume] { getContexts: func() []ContextConfig[*commands.Volume] {
return []ContextConfig[*commands.Volume]{ return []ContextConfig[*commands.Volume]{
{ {
@ -48,6 +49,9 @@ func (gui *Gui) getVolumesPanel() *SideListPanel[*commands.Volume] {
} }
return a.Name < b.Name 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 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 { func (gui *Gui) handleVolumesRemoveMenu(g *gocui.Gui, v *gocui.View) error {
volume, err := gui.Panels.Volumes.GetSelectedItem() volume, err := gui.Panels.Volumes.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
type removeVolumeOption struct {
description string
command string
force bool
}
options := []*removeVolumeOption{ options := []*removeVolumeOption{
{ {
description: gui.Tr.Remove, description: gui.Tr.Remove,
command: utils.WithShortSha("docker volume rm " + volume.Name), command: utils.WithShortSha("docker volume rm " + volume.Name),
force: false, force: false,
runCommand: true,
}, },
{ {
description: gui.Tr.ForceRemove, description: gui.Tr.ForceRemove,
command: utils.WithShortSha("docker volume rm --force " + volume.Name), command: utils.WithShortSha("docker volume rm --force " + volume.Name),
force: true, force: true,
runCommand: true,
},
{
description: gui.Tr.Cancel,
runCommand: false,
}, },
} }
handleMenuPress := func(index int) error { menuItems := lo.Map(options, func(option *removeVolumeOption, _ int) *MenuItem {
if !options[index].runCommand { return &MenuItem{
return nil 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 { func (gui *Gui) handlePruneVolumes() error {

View file

@ -6,7 +6,6 @@ import (
"html/template" "html/template"
"io" "io"
"math" "math"
"reflect"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@ -98,71 +97,6 @@ func Max(x, y int) int {
return y 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 // RenderTable takes an array of string arrays and returns a table containing the values
func RenderTable(stringArrays [][]string) (string, error) { func RenderTable(stringArrays [][]string) (string, error) {
if len(stringArrays) == 0 { if len(stringArrays) == 0 {
@ -225,14 +159,6 @@ func displayArraysAligned(stringArrays [][]string) bool {
return true 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 { func FormatBinaryBytes(b int) string {
n := float64(b) n := float64(b)
units := []string{"B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"} 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 return x
} }
// Style used on menu items that open another menu
func OpensMenuStyle(str string) string {
return ColoredString(fmt.Sprintf("%s...", str), color.FgMagenta)
}

View file

@ -3,6 +3,7 @@ package utils
import ( import (
"testing" "testing"
"github.com/go-errors/errors"
"github.com/stretchr/testify/assert" "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. // TestGetPaddedDisplayStrings is a function.
func TestGetPaddedDisplayStrings(t *testing.T) { func TestGetPaddedDisplayStrings(t *testing.T) {
type scenario struct { type scenario struct {
@ -380,3 +221,39 @@ func TestGetPadWidths(t *testing.T) {
assert.EqualValues(t, s.expected, getPadWidths(s.stringArrays)) 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)
}
}
}