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 ./...` 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/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 5410502d..a638ee29 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -87,15 +87,14 @@ 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 { - // 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. + // 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 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 a7c6ef55..e41f7b8f 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() }, } } @@ -99,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 { @@ -195,6 +191,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() }, } } 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