From c84e41f5c14c820e95adb720b92145719bc1e5ff Mon Sep 17 00:00:00 2001 From: Rayzien Date: Mon, 21 Sep 2026 00:28:14 +0530 Subject: [PATCH] Fix multiple issues (#840, #817, #836, #767, #828, #833, #748, #800, #784, #818) Includes fixes for deadlock, docker label argument injection, start container command, force remove options, and UI visibility options. --- go.mod | 2 -- pkg/commands/container.go | 4 +-- pkg/commands/docker.go | 41 +++++++++++++++++------- pkg/commands/image.go | 10 +++++- pkg/commands/service.go | 9 ++++++ pkg/config/app_config.go | 4 +++ pkg/gui/containers_panel.go | 18 +++++++++++ pkg/gui/gui.go | 6 ++-- pkg/gui/images_panel.go | 16 ++++++++++ pkg/gui/keybindings.go | 7 ++++ pkg/gui/presentation/containers.go | 51 +++++++++++++++++++++--------- pkg/gui/services_panel.go | 8 +---- pkg/i18n/english.go | 10 ++++-- pkg/tasks/tasks.go | 8 ++--- 14 files changed, 145 insertions(+), 49 deletions(-) diff --git a/go.mod b/go.mod index bf2c2236e..a099c523d 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,6 @@ module github.com/jesseduffield/lazydocker go 1.22 -toolchain go1.23.6 require ( github.com/OpenPeeDeeP/xdg v0.2.1-0.20190312153938-4ba9e1eb294c @@ -25,7 +24,6 @@ require ( github.com/mgutz/str v1.2.0 github.com/pmezard/go-difflib v1.0.0 github.com/samber/lo v1.31.0 - github.com/sasha-s/go-deadlock v0.3.1 github.com/sirupsen/logrus v1.9.3 github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad github.com/stretchr/testify v1.9.0 diff --git a/pkg/commands/container.go b/pkg/commands/container.go index 8e33ff600..af45b37a3 100644 --- a/pkg/commands/container.go +++ b/pkg/commands/container.go @@ -12,7 +12,7 @@ import ( "github.com/go-errors/errors" "github.com/jesseduffield/lazydocker/pkg/i18n" "github.com/jesseduffield/lazydocker/pkg/utils" - "github.com/sasha-s/go-deadlock" + "sync" "github.com/sirupsen/logrus" "golang.org/x/xerrors" ) @@ -37,7 +37,7 @@ type Container struct { DockerCommand LimitedDockerCommand Tr *i18n.TranslationSet - StatsMutex deadlock.Mutex + StatsMutex sync.Mutex } // Remove removes the container diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index b055441a8..c44138b61 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -25,7 +25,7 @@ import ( "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/i18n" "github.com/jesseduffield/lazydocker/pkg/utils" - "github.com/sasha-s/go-deadlock" + "sync" "github.com/sirupsen/logrus" ) @@ -44,8 +44,8 @@ type DockerCommand struct { // LocalProjectName is the compose project name for the directory where lazydocker was launched. LocalProjectName string ErrorChan chan error - ContainerMutex deadlock.Mutex - ServiceMutex deadlock.Mutex + ContainerMutex sync.Mutex + ServiceMutex sync.Mutex Closers []io.Closer } @@ -76,9 +76,9 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject { // When operating on a specific project, include -p flag so that // docker compose targets the correct project. if obj.Service != nil && obj.Service.ProjectName != "" { - defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, obj.Service.ProjectName) + defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, c.OSCommand.Quote(obj.Service.ProjectName)) } else if obj.Project != nil && obj.Project.Name != "" { - defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, obj.Project.Name) + defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, c.OSCommand.Quote(obj.Project.Name)) } return defaultObj @@ -170,6 +170,9 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat // because the user passed -p. When false, the project/services panels are // hidden and all containers are shown in a flat list. func (c *DockerCommand) IsProjectScoped() bool { + if c.Config.UserConfig.Gui.ProjectAndServicePanelsOutsideCompose { + return true + } return c.InDockerComposeProject || c.Config.ProjectName != "" } @@ -248,20 +251,34 @@ func (c *DockerCommand) RefreshContainersAndServices(currentContainers []*Contai // labels to handle cases where the project name differs from the directory // name (e.g. a `name:` directive in the compose file). if c.LocalProjectName == "" && c.InDockerComposeProject && composeServices != nil { + // First try to match by working directory (Docker Compose V2) for _, ctr := range containers { - if ctr.ProjectName == "" || ctr.ServiceName == "" { - continue - } - for _, svc := range composeServices { - if ctr.ServiceName == svc.Name { + if wd, ok := ctr.Container.Labels["com.docker.compose.project.working_dir"]; ok { + if wd == c.Config.ProjectDir && ctr.ProjectName != "" { c.LocalProjectName = ctr.ProjectName break } } - if c.LocalProjectName != "" { - break + } + + // Fall back to service name matching (Docker Compose V1) + if c.LocalProjectName == "" { + for _, ctr := range containers { + if ctr.ProjectName == "" || ctr.ServiceName == "" { + continue + } + for _, svc := range composeServices { + if ctr.ServiceName == svc.Name { + c.LocalProjectName = ctr.ProjectName + break + } + } + if c.LocalProjectName != "" { + break + } } } + // Fall back to directory name if c.LocalProjectName == "" && c.Config.ProjectDir != "" { c.LocalProjectName = filepath.Base(c.Config.ProjectDir) diff --git a/pkg/commands/image.go b/pkg/commands/image.go index 789733d0e..29f635e60 100644 --- a/pkg/commands/image.go +++ b/pkg/commands/image.go @@ -136,8 +136,16 @@ func (c *DockerCommand) RefreshImages() ([]*Image, error) { return ownImages, nil } -// PruneImages prunes images +// PruneImages prunes dangling images func (c *DockerCommand) PruneImages() error { _, err := c.Client.ImagesPrune(context.Background(), filters.Args{}) return err } + +// PruneUnusedImages prunes all unused images (not just dangling) +func (c *DockerCommand) PruneUnusedImages() error { + pruneFilters := filters.NewArgs() + pruneFilters.Add("dangling", "false") + _, err := c.Client.ImagesPrune(context.Background(), pruneFilters) + return err +} diff --git a/pkg/commands/service.go b/pkg/commands/service.go index 5006eefea..5c870d685 100644 --- a/pkg/commands/service.go +++ b/pkg/commands/service.go @@ -35,6 +35,15 @@ func (s *Service) Up() error { return s.runCommand(s.OSCommand.Config.UserConfig.CommandTemplates.UpService) } +// UpCmd returns the command to up the service +func (s *Service) UpCmd() *exec.Cmd { + command := utils.ApplyTemplate( + s.OSCommand.Config.UserConfig.CommandTemplates.UpService, + s.DockerCommand.NewCommandObject(CommandObject{Service: s}), + ) + return s.OSCommand.ExecutableFromString(command) +} + // Restart restarts the service func (s *Service) Restart() error { return s.runCommand(s.OSCommand.Config.UserConfig.CommandTemplates.RestartService) diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 0ebc17969..eaacbdf7e 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -105,6 +105,10 @@ type GuiConfig struct { // of clutter ShowAllContainers bool `yaml:"showAllContainers,omitempty"` + // ProjectAndServicePanelsOutsideCompose determines if project and service panels + // should be shown even when lazydocker is started outside of a docker-compose directory. + ProjectAndServicePanelsOutsideCompose bool `yaml:"projectAndServicePanelsOutsideCompose,omitempty"` + // ReturnImmediately determines whether you get the 'press enter to return to // lazydocker' message after a subprocess has completed. You would set this to // true if you often want to see the output of subprocesses before returning diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index a638ee290..75a264d17 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -351,6 +351,10 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error { LabelColumns: []string{gui.Tr.RemoveWithVolumes, "docker rm --volumes " + ctr.ID[1:10]}, OnPress: func() error { return handleMenuPress(container.RemoveOptions{RemoveVolumes: true}) }, }, + { + LabelColumns: []string{gui.Tr.ForceRemove, "docker rm --force " + ctr.ID[1:10]}, + OnPress: func() error { return handleMenuPress(container.RemoveOptions{Force: true}) }, + }, } return gui.Menu(CreateMenuOptions{ @@ -401,6 +405,20 @@ func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error { }, nil) } +func (gui *Gui) handleContainerStart(g *gocui.Gui, v *gocui.View) error { + ctr, err := gui.Panels.Containers.GetSelectedItem() + if err != nil { + return nil + } + + return gui.WithWaitingStatus(gui.Tr.StartingStatus, func() error { + if err := ctr.Start(); err != nil { + return gui.createErrorPanel(err.Error()) + } + return gui.refreshContainersAndServices() + }) +} + func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error { ctr, err := gui.Panels.Containers.GetSelectedItem() if err != nil { diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 6ea2ee6c6..63ec5071e 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -19,7 +19,7 @@ import ( "github.com/jesseduffield/lazydocker/pkg/gui/types" "github.com/jesseduffield/lazydocker/pkg/i18n" "github.com/jesseduffield/lazydocker/pkg/tasks" - "github.com/sasha-s/go-deadlock" + "sync" "github.com/sirupsen/logrus" ) @@ -58,8 +58,8 @@ type Panels struct { } type Mutexes struct { - SubprocessMutex deadlock.Mutex - ViewStackMutex deadlock.Mutex + SubprocessMutex sync.Mutex + ViewStackMutex sync.Mutex } type mainPanelState struct { diff --git a/pkg/gui/images_panel.go b/pkg/gui/images_panel.go index 7b0d56437..419dcce89 100644 --- a/pkg/gui/images_panel.go +++ b/pkg/gui/images_panel.go @@ -191,6 +191,18 @@ func (gui *Gui) handlePruneImages() error { }, nil) } +func (gui *Gui) handlePruneAllImages() error { + return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmPruneAllImages, func(g *gocui.Gui, v *gocui.View) error { + return gui.WithWaitingStatus(gui.Tr.PruningStatus, func() error { + err := gui.DockerCommand.PruneUnusedImages() + if err != nil { + return gui.createErrorPanel(err.Error()) + } + return gui.reloadImages() + }) + }, nil) +} + func (gui *Gui) handleImagesCustomCommand(g *gocui.Gui, v *gocui.View) error { img, err := gui.Panels.Images.GetSelectedItem() if err != nil { @@ -212,6 +224,10 @@ func (gui *Gui) handleImagesBulkCommand(g *gocui.Gui, v *gocui.View) error { Name: gui.Tr.PruneImages, InternalFunction: gui.handlePruneImages, }, + { + Name: gui.Tr.PruneAllImages, + InternalFunction: gui.handlePruneAllImages, + }, } bulkCommands := append(baseBulkCommands, gui.Config.UserConfig.BulkCommands.Images...) diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index df7060241..4b9b1db51 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -213,6 +213,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Handler: gui.handleContainerStop, Description: gui.Tr.Stop, }, + { + ViewName: "containers", + Key: 'S', + Modifier: gocui.ModNone, + Handler: gui.handleContainerStart, + Description: gui.Tr.Start, + }, { ViewName: "containers", Key: 'r', diff --git a/pkg/gui/presentation/containers.go b/pkg/gui/presentation/containers.go index 173a1e8ac..57ae90014 100644 --- a/pkg/gui/presentation/containers.go +++ b/pkg/gui/presentation/containers.go @@ -91,14 +91,27 @@ func getContainerDisplayStatus(guiConfig *config.GuiConfig, c *commands.Containe // GetDisplayStatus returns the exit code if the container has exited, and the health status if the container is running (and has a health check) func getContainerDisplaySubstatus(guiConfig *config.GuiConfig, c *commands.Container) string { - if !c.DetailsLoaded() { - return "" - } - switch c.Container.State { case "exited": + exitCode := "" + if c.DetailsLoaded() && c.Details.State != nil { + exitCode = strconv.Itoa(c.Details.State.ExitCode) + } else { + // Parse from summary status string (e.g. "Exited (137) 2 minutes ago") + status := c.Container.Status + if strings.HasPrefix(status, "Exited (") { + if endIdx := strings.Index(status, ")"); endIdx > 8 { + exitCode = status[8:endIdx] + } + } + } + + if exitCode == "" { + return "" + } + return utils.ColoredString( - fmt.Sprintf("(%s)", strconv.Itoa(c.Details.State.ExitCode)), getContainerColor(c), + fmt.Sprintf("(%s)", exitCode), getContainerColor(c), ) case "running": return getHealthStatus(guiConfig, c) @@ -108,8 +121,20 @@ func getContainerDisplaySubstatus(guiConfig *config.GuiConfig, c *commands.Conta } func getHealthStatus(guiConfig *config.GuiConfig, c *commands.Container) string { - if !c.DetailsLoaded() { - return "" + var status string + if c.DetailsLoaded() && c.Details.State != nil && c.Details.State.Health != nil { + status = c.Details.State.Health.Status + } else { + // Fallback to parsing from summary status string (e.g. "Up 2 hours (healthy)") + if strings.Contains(c.Container.Status, "(healthy)") { + status = "healthy" + } else if strings.Contains(c.Container.Status, "(unhealthy)") { + status = "unhealthy" + } else if strings.Contains(c.Container.Status, "(health: starting)") { + status = "starting" + } else { + return "" + } } healthStatusColorMap := map[string]color.Attribute{ @@ -118,10 +143,6 @@ func getHealthStatus(guiConfig *config.GuiConfig, c *commands.Container) string "starting": color.FgYellow, } - if c.Details.State.Health == nil { - return "" - } - shortHealthStatusMap := map[string]string{ "healthy": "H", "unhealthy": "U", @@ -137,16 +158,16 @@ func getHealthStatus(guiConfig *config.GuiConfig, c *commands.Container) string var healthStatus string switch guiConfig.ContainerStatusHealthStyle { case "short": - healthStatus = shortHealthStatusMap[c.Details.State.Health.Status] + healthStatus = shortHealthStatusMap[status] case "icon": - healthStatus = string(iconHealthStatusMap[c.Details.State.Health.Status]) + healthStatus = string(iconHealthStatusMap[status]) case "long": fallthrough default: - healthStatus = c.Details.State.Health.Status + healthStatus = status } - if healthStatusColor, ok := healthStatusColorMap[c.Details.State.Health.Status]; ok { + if healthStatusColor, ok := healthStatusColorMap[status]; ok { return utils.ColoredString(fmt.Sprintf("(%s)", healthStatus), healthStatusColor) } return "" diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go index edc894e9e..447e8b04d 100644 --- a/pkg/gui/services_panel.go +++ b/pkg/gui/services_panel.go @@ -249,13 +249,7 @@ func (gui *Gui) handleServiceUp(g *gocui.Gui, v *gocui.View) error { return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService) } - return gui.WithWaitingStatus(gui.Tr.UppingServiceStatus, func() error { - if err := service.Up(); err != nil { - return gui.createErrorPanel(err.Error()) - } - - return nil - }) + return gui.runSubprocess(service.UpCmd()) } func (gui *Gui) handleServiceRestart(g *gocui.Gui, v *gocui.View) error { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d86b0c84f..b9a679fca 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -87,6 +87,7 @@ type TranslationSet struct { RemoveWithoutPruneWithForce string RemoveWithForce string PruneImages string + PruneAllImages string PruneContainers string PruneVolumes string PruneNetworks string @@ -94,6 +95,7 @@ type TranslationSet struct { ConfirmStopContainers string ConfirmRemoveContainers string ConfirmPruneImages string + ConfirmPruneAllImages string ConfirmPruneVolumes string ConfirmPruneNetworks string PruningStatus string @@ -207,7 +209,8 @@ func englishSet() TranslationSet { PruneContainers: "prune exited containers", PruneVolumes: "prune unused volumes", PruneNetworks: "prune unused networks", - PruneImages: "prune unused images", + PruneImages: "prune dangling images", + PruneAllImages: "prune ALL unused images (not just dangling)", StopAllContainers: "stop all containers", RemoveAllContainers: "remove all containers (forced)", ViewRestartOptions: "view restart options", @@ -255,10 +258,11 @@ func englishSet() TranslationSet { ConfirmUpProject: "Are you sure you want to 'up' your docker compose project?", MustForceToRemoveContainer: "You cannot remove a running container unless you force it. Do you want to force it?", NotEnoughSpace: "Not enough space to render panels", - ConfirmPruneImages: "Are you sure you want to prune all unused images?", - ConfirmPruneContainers: "Are you sure you want to prune all stopped containers?", + ConfirmPruneContainers: "Are you sure you want to prune all exited containers?", ConfirmStopContainers: "Are you sure you want to stop all containers?", ConfirmRemoveContainers: "Are you sure you want to remove all containers?", + ConfirmPruneImages: "Are you sure you want to prune all dangling images?", + ConfirmPruneAllImages: "Are you sure you want to prune ALL unused images?", ConfirmPruneVolumes: "Are you sure you want to prune all unused volumes?", ConfirmPruneNetworks: "Are you sure you want to prune all unused networks?", StopService: "Are you sure you want to stop this service's containers?", diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 06fac38f4..6fe3c3b7a 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -6,14 +6,14 @@ import ( "time" "github.com/jesseduffield/lazydocker/pkg/i18n" - "github.com/sasha-s/go-deadlock" + "sync" "github.com/sirupsen/logrus" ) type TaskManager struct { currentTask *Task - waitingMutex deadlock.Mutex - taskIDMutex deadlock.Mutex + waitingMutex sync.Mutex + taskIDMutex sync.Mutex Log *logrus.Entry Tr *i18n.TranslationSet newTaskId int @@ -23,7 +23,7 @@ type Task struct { ctx context.Context cancel context.CancelFunc stopped bool - stopMutex deadlock.Mutex + stopMutex sync.Mutex notifyStopped chan struct{} Log *logrus.Entry f func(ctx context.Context)