From ebce4fc6317e3cf466902c75185f77f03fa5c852 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:20:36 +0000 Subject: [PATCH 1/6] Initial plan From e3c1c8630ae77af0eabca15e00aa270ffbb1d21b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:26:03 +0000 Subject: [PATCH 2/6] Hide project/services panels when not in a docker-compose project Co-authored-by: jesseduffield <8456633+jesseduffield@users.noreply.github.com> --- pkg/gui/arrangement.go | 31 +++++++++++++++++++------------ pkg/gui/containers_panel.go | 21 +++++++++++++-------- pkg/gui/project_panel.go | 8 ++++++++ pkg/gui/services_panel.go | 8 ++++++-- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/pkg/gui/arrangement.go b/pkg/gui/arrangement.go index 744f4467..9aceec70 100644 --- a/pkg/gui/arrangement.go +++ b/pkg/gui/arrangement.go @@ -176,23 +176,30 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box { } // The project panel is compact (Size: 3) when not focused, but expands - // when focused to show the list of projects. - projectBox := &boxlayout.Box{ - Window: sideWindowNames[0], - Size: 3, - } - if currentWindow == sideWindowNames[0] { - projectBox = &boxlayout.Box{ + // when focused to show the list of projects. This only applies when the + // project panel is actually visible (i.e. we are inside a compose project). + if len(sideWindowNames) > 0 && sideWindowNames[0] == "project" { + projectBox := &boxlayout.Box{ Window: sideWindowNames[0], - Weight: 2, + Size: 3, } + if currentWindow == sideWindowNames[0] { + projectBox = &boxlayout.Box{ + Window: sideWindowNames[0], + Weight: 2, + } + } + + return append([]*boxlayout.Box{ + projectBox, + }, lo.Map(sideWindowNames[1:], func(window string, _ int) *boxlayout.Box { + return accordionBox(&boxlayout.Box{Window: window, Weight: 1}) + })...) } - return append([]*boxlayout.Box{ - projectBox, - }, lo.Map(sideWindowNames[1:], func(window string, _ int) *boxlayout.Box { + return lo.Map(sideWindowNames, func(window string, _ int) *boxlayout.Box { return accordionBox(&boxlayout.Box{Window: window, Weight: 1}) - })...) + }) } else { squashedHeight := 1 if height >= 21 { diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index 4bde93ec..cf4b082c 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -94,14 +94,19 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container] return false } - // Filter by selected project. Containers with no project (truly - // standalone, not from any compose project) are always shown. - selectedProject := gui.getSelectedProjectName() - if selectedProject == "" { - selectedProject = gui.DockerCommand.LocalProjectName - } - if selectedProject != "" && container.ProjectName != "" && container.ProjectName != selectedProject { - return false + // Only apply project filtering when we are inside a docker-compose + // project. Outside of a compose project all containers are shown in + // a flat list regardless of which compose project they belong to. + if gui.DockerCommand.InDockerComposeProject { + // Filter by selected project. Containers with no project (truly + // standalone, not from any compose project) are always shown. + selectedProject := gui.getSelectedProjectName() + if selectedProject == "" { + selectedProject = gui.DockerCommand.LocalProjectName + } + if selectedProject != "" && container.ProjectName != "" && container.ProjectName != selectedProject { + return false + } } return true diff --git a/pkg/gui/project_panel.go b/pkg/gui/project_panel.go index ca9acf28..a7c6ef55 100644 --- a/pkg/gui/project_panel.go +++ b/pkg/gui/project_panel.go @@ -58,6 +58,14 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] { // containers to show only those belonging to the selected project. return gui.renderContainersAndServices() }, + Hide: func() bool { + // Only show the project panel when we are inside a docker-compose + // project directory. When launched outside of a compose project + // there is no meaningful local project to display, so we hide the + // panel and let the containers panel show all containers in a flat + // list (matching the behaviour from before v0.25). + return !gui.DockerCommand.InDockerComposeProject + }, } } diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go index fb768a74..878eaa4b 100644 --- a/pkg/gui/services_panel.go +++ b/pkg/gui/services_panel.go @@ -90,8 +90,12 @@ func (gui *Gui) getServicesPanel() *panels.SideListPanel[*commands.Service] { return presentation.GetServiceDisplayStrings(&gui.Config.UserConfig.Gui, service) }, Hide: func() bool { - // Show services panel if there are any compose projects (local or discovered) - return !gui.DockerCommand.InDockerComposeProject && len(gui.Panels.Services.List.GetAllItems()) == 0 + // Only show the services panel when we are inside a docker-compose + // project directory. When launched outside of a compose project + // there is no local project context, so the panel is hidden and + // all containers are shown in a flat list (matching pre-v0.25 + // behaviour). + return !gui.DockerCommand.InDockerComposeProject }, } } From b17d4741482edb32079c4c28cecc8e43c3e057b5 Mon Sep 17 00:00:00 2001 From: Daniel Dibiasi <10942109+ddibiasi@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:23:12 +0200 Subject: [PATCH 3/6] Fixed forced project view --- pkg/gui/containers_panel.go | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index cf4b082c..5410502d 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -83,21 +83,27 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container] return sortContainers(a, b, gui.Config.UserConfig.Gui.LegacySortContainers) }, Filter: func(container *commands.Container) bool { - // Note that this is O(N*M) time complexity where N is the number of services - // and M is the number of containers. We expect N to be small but M may be large, - // so we will need to keep an eye on this. - if !gui.Config.UserConfig.Gui.ShowAllContainers && !isStandaloneContainer(container) { - return false - } - if !gui.State.ShowExitedContainers && container.Container.State == "exited" { return false } - // Only apply project filtering when we are inside a docker-compose - // project. Outside of a compose project all containers are shown in - // a flat list regardless of which compose project they belong to. + // Only apply project and standalone filtering when we are inside a + // docker-compose project. Outside of a compose project all + // containers are shown in a flat list regardless of which compose + // project they belong to. if gui.DockerCommand.InDockerComposeProject { + // This check must be inside the InDockerComposeProject guard: + // outside a compose project, services are still derived from + // container labels, so compose-managed containers from other + // projects would be incorrectly hidden. + // + // Note that this is O(N*M) time complexity where N is the number of services + // and M is the number of containers. We expect N to be small but M may be large, + // so we will need to keep an eye on this. + if !gui.Config.UserConfig.Gui.ShowAllContainers && !isStandaloneContainer(container) { + return false + } + // Filter by selected project. Containers with no project (truly // standalone, not from any compose project) are always shown. selectedProject := gui.getSelectedProjectName() From 697cd441aafa2fd3248267c5f1ef1dc36a4726f0 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 19 Apr 2026 12:16:43 +1000 Subject: [PATCH 4/6] Add some claude stuff --- .claude/settings.json | 4 ++-- CLAUDE.md | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md diff --git a/.claude/settings.json b/.claude/settings.json index 4fd816c7..f0d9024c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,11 +2,11 @@ "hooks": { "PostToolUse": [ { - "matcher": "Edit|Write", + "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", - "command": "jq -r '.tool_input.file_path // empty' | xargs -I{} gofumpt -w {}" + "command": "jq -r '.tool_input.file_path // empty' | { read -r f; case \"$f\" in *.go) gofumpt -w \"$f\" ;; esac; } 2>/dev/null || true" } ] } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d1f42b8c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +## Build & test +- All Go commands need `GOFLAGS=-mod=vendor` (deps are vendored, including the `jesseduffield/gocui` fork and the Docker SDK). +- Unit tests: `GOFLAGS=-mod=vendor go test ./...` From 3974f6fec1f33b9454e59d77ed7372eed2e35f69 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 19 Apr 2026 12:16:55 +1000 Subject: [PATCH 5/6] Support -p flag and DRY up code --- pkg/commands/docker.go | 16 ++++++++++++++++ pkg/gui/containers_panel.go | 9 ++++----- pkg/gui/project_panel.go | 12 ++++++------ pkg/gui/services_panel.go | 7 +------ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 0ce4fba1..b055441a 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -154,9 +154,25 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat log.Warn(err.Error()) } + // When the user passes -p outside of a compose directory, treat it as the + // local project so the project/services panels still appear and filtering + // is applied. Inside a compose dir, LocalProjectName is derived from + // container labels later in RefreshContainersAndServices. + if !dockerCommand.InDockerComposeProject && config.ProjectName != "" { + dockerCommand.LocalProjectName = config.ProjectName + } + return dockerCommand, nil } +// IsProjectScoped reports whether lazydocker should be scoped to a single +// compose project — either because we're inside a compose directory or +// 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 { + return c.InDockerComposeProject || c.Config.ProjectName != "" +} + func (c *DockerCommand) setDockerComposeCommand(config *config.AppConfig) { if config.UserConfig.CommandTemplates.DockerCompose != "docker compose" { return diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index 5410502d..f9254b94 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -87,11 +87,10 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container] return false } - // Only apply project and standalone filtering when we are inside a - // docker-compose project. Outside of a compose project all - // containers are shown in a flat list regardless of which compose - // project they belong to. - if gui.DockerCommand.InDockerComposeProject { + // When project-scoped, apply project and standalone filtering. + // Otherwise all containers are shown in a flat list regardless + // of which compose project they belong to. + if gui.DockerCommand.IsProjectScoped() { // This check must be inside the InDockerComposeProject guard: // outside a compose project, services are still derived from // container labels, so compose-managed containers from other diff --git a/pkg/gui/project_panel.go b/pkg/gui/project_panel.go index a7c6ef55..13e281ca 100644 --- a/pkg/gui/project_panel.go +++ b/pkg/gui/project_panel.go @@ -59,12 +59,7 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] { return gui.renderContainersAndServices() }, Hide: func() bool { - // Only show the project panel when we are inside a docker-compose - // project directory. When launched outside of a compose project - // there is no meaningful local project to display, so we hide the - // panel and let the containers panel show all containers in a flat - // list (matching the behaviour from before v0.25). - return !gui.DockerCommand.InDockerComposeProject + return !gui.DockerCommand.IsProjectScoped() }, } } @@ -195,6 +190,11 @@ func (gui *Gui) renderAllLogs(project *commands.Project) tasks.TaskFunc { } func (gui *Gui) renderDockerComposeConfig(project *commands.Project) tasks.TaskFunc { + if !gui.DockerCommand.InDockerComposeProject { + return gui.NewSimpleRenderStringTask(func() string { + return "Compose config is only available when launched from a docker-compose project directory" + }) + } if project != nil && project.Name != gui.DockerCommand.LocalProjectName { return gui.NewSimpleRenderStringTask(func() string { return "Compose config is not available for non-local projects" diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go index 878eaa4b..edc894e9 100644 --- a/pkg/gui/services_panel.go +++ b/pkg/gui/services_panel.go @@ -90,12 +90,7 @@ func (gui *Gui) getServicesPanel() *panels.SideListPanel[*commands.Service] { return presentation.GetServiceDisplayStrings(&gui.Config.UserConfig.Gui, service) }, Hide: func() bool { - // Only show the services panel when we are inside a docker-compose - // project directory. When launched outside of a compose project - // there is no local project context, so the panel is hidden and - // all containers are shown in a flat list (matching pre-v0.25 - // behaviour). - return !gui.DockerCommand.InDockerComposeProject + return !gui.DockerCommand.IsProjectScoped() }, } } From f5ff116af920c2ad8a094c77845f6e32b105f156 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 19 Apr 2026 12:47:30 +1000 Subject: [PATCH 6/6] Use IsProjectScoped at remaining call sites + add test Addresses Copilot review comments on PR #797: - Containers panel title, initial focus selection, and the local-project inclusion in getDiscoveredProjects all switched from InDockerComposeProject to IsProjectScoped, so they stay consistent when -p is given outside a compose dir. - Adds TestIsProjectScoped table test covering all four combinations. InDockerComposeProject is still used at the call sites that genuinely require a compose dir (renderDockerComposeConfig, GetServices). Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/commands/docker_test.go | 28 ++++++++++++++++++++++++++++ pkg/gui/containers_panel.go | 8 ++++---- pkg/gui/gui.go | 2 +- pkg/gui/project_panel.go | 11 ++++++----- pkg/gui/views.go | 2 +- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/pkg/commands/docker_test.go b/pkg/commands/docker_test.go index 0d459686..db45a06f 100644 --- a/pkg/commands/docker_test.go +++ b/pkg/commands/docker_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/docker/docker/client" + "github.com/jesseduffield/lazydocker/pkg/config" "github.com/stretchr/testify/assert" ) @@ -61,3 +62,30 @@ func TestNewDockerClientVersionNegotiation(t *testing.T) { "client version should not be locked to DOCKER_API_VERSION env var") }) } + +// TestIsProjectScoped covers the predicate that drives whether the +// project/services panels appear and whether the containers panel filters by +// project. The "outside compose dir + -p" case is the regression we fixed +// after PR #776 silently disabled it. +func TestIsProjectScoped(t *testing.T) { + cases := []struct { + name string + inDockerComposeProject bool + projectName string + want bool + }{ + {"inside compose dir, no -p", true, "", true}, + {"inside compose dir, with -p", true, "myproject", true}, + {"outside compose dir, no -p", false, "", false}, + {"outside compose dir, with -p", false, "myproject", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := &DockerCommand{ + InDockerComposeProject: tc.inDockerComposeProject, + Config: &config.AppConfig{ProjectName: tc.projectName}, + } + assert.Equal(t, tc.want, c.IsProjectScoped()) + }) + } +} diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index f9254b94..a638ee29 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -91,10 +91,10 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container] // Otherwise all containers are shown in a flat list regardless // of which compose project they belong to. if gui.DockerCommand.IsProjectScoped() { - // This check must be inside the InDockerComposeProject guard: - // outside a compose project, services are still derived from - // container labels, so compose-managed containers from other - // projects would be incorrectly hidden. + // This check must be inside the IsProjectScoped guard: when + // not project-scoped, services are still derived from container + // labels, so compose-managed containers from other projects + // would be incorrectly hidden. // // Note that this is O(N*M) time complexity where N is the number of services // and M is the number of containers. We expect N to be small but M may be large, diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e7b55463..6ea2ee6c 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -459,7 +459,7 @@ func (gui *Gui) ShouldRefresh(key string) bool { } func (gui *Gui) initiallyFocusedViewName() string { - if gui.DockerCommand.InDockerComposeProject { + if gui.DockerCommand.IsProjectScoped() { return "services" } return "containers" diff --git a/pkg/gui/project_panel.go b/pkg/gui/project_panel.go index 13e281ca..e41f7b8f 100644 --- a/pkg/gui/project_panel.go +++ b/pkg/gui/project_panel.go @@ -94,18 +94,19 @@ func (gui *Gui) refreshProject() error { } // getDiscoveredProjects returns all docker compose projects by examining container labels. -// The local project (from docker-compose.yml in the current directory) is included if -// it has running containers or if InDockerComposeProject is true. +// The local project (from docker-compose.yml in the current directory, or from -p) is +// included even when it has no running containers, so the user always sees the project +// they explicitly scoped to. func (gui *Gui) getDiscoveredProjects() []*commands.Project { containers := gui.Panels.Containers.List.GetAllItems() projectNames := gui.DockerCommand.GetProjectNames(containers) - // If we're in a docker compose project but it has no running containers, - // still include it. We don't fall back to the directory name here to avoid + // If we're scoped to a project but it has no running containers, still + // include it. We don't fall back to the directory name here to avoid // briefly flashing the wrong project name on startup. localName := gui.DockerCommand.LocalProjectName - if gui.DockerCommand.InDockerComposeProject && localName != "" { + if gui.DockerCommand.IsProjectScoped() && localName != "" { found := false for _, name := range projectNames { if name == localName { diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 95553f5a..f231c3c6 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -130,7 +130,7 @@ func (gui *Gui) createAllViews() error { gui.Views.Containers.Highlight = true gui.Views.Containers.SelBgColor = selectedLineBgColor - if gui.Config.UserConfig.Gui.ShowAllContainers || !gui.DockerCommand.InDockerComposeProject { + if gui.Config.UserConfig.Gui.ShowAllContainers || !gui.DockerCommand.IsProjectScoped() { gui.Views.Containers.Title = gui.Tr.ContainersTitle } else { gui.Views.Containers.Title = gui.Tr.StandaloneContainersTitle