Compare commits

..

9 commits

Author SHA1 Message Date
Jesse Duffield
7e7aadc207
Merge pull request #797 from jesseduffield/support-p-flag
Support -p flag again
2026-04-19 12:50:06 +10:00
Jesse Duffield
f5ff116af9 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) <noreply@anthropic.com>
2026-04-19 12:47:30 +10:00
Jesse Duffield
3974f6fec1 Support -p flag and DRY up code 2026-04-19 12:16:55 +10:00
Jesse Duffield
697cd441aa Add some claude stuff 2026-04-19 12:16:43 +10:00
Jesse Duffield
9134abeefd
Merge pull request #776 from jesseduffield/copilot/disable-forced-project-view
Hide project/services panels when not in a docker-compose project directory
2026-04-19 11:55:51 +10:00
Jesse Duffield
8106125441
Merge pull request #795 from ddibiasi/copilot/disable-forced-project-view
Fixed forced project view
2026-04-19 11:55:00 +10:00
Daniel Dibiasi
b17d474148
Fixed forced project view 2026-04-16 09:23:12 +02:00
copilot-swe-agent[bot]
e3c1c8630a Hide project/services panels when not in a docker-compose project
Co-authored-by: jesseduffield <8456633+jesseduffield@users.noreply.github.com>
2026-03-15 22:26:03 +00:00
copilot-swe-agent[bot]
ebce4fc631 Initial plan 2026-03-15 22:20:36 +00:00
10 changed files with 112 additions and 38 deletions

View file

@ -2,11 +2,11 @@
"hooks": { "hooks": {
"PostToolUse": [ "PostToolUse": [
{ {
"matcher": "Edit|Write", "matcher": "Edit|Write|MultiEdit",
"hooks": [ "hooks": [
{ {
"type": "command", "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"
} }
] ]
} }

5
CLAUDE.md Normal file
View file

@ -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 ./...`

View file

@ -154,9 +154,25 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat
log.Warn(err.Error()) 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 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) { func (c *DockerCommand) setDockerComposeCommand(config *config.AppConfig) {
if config.UserConfig.CommandTemplates.DockerCompose != "docker compose" { if config.UserConfig.CommandTemplates.DockerCompose != "docker compose" {
return return

View file

@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/stretchr/testify/assert" "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") "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())
})
}
}

View file

@ -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 // The project panel is compact (Size: 3) when not focused, but expands
// when focused to show the list of projects. // when focused to show the list of projects. This only applies when the
projectBox := &boxlayout.Box{ // project panel is actually visible (i.e. we are inside a compose project).
Window: sideWindowNames[0], if len(sideWindowNames) > 0 && sideWindowNames[0] == "project" {
Size: 3, projectBox := &boxlayout.Box{
}
if currentWindow == sideWindowNames[0] {
projectBox = &boxlayout.Box{
Window: sideWindowNames[0], 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{ return lo.Map(sideWindowNames, func(window string, _ int) *boxlayout.Box {
projectBox,
}, lo.Map(sideWindowNames[1:], func(window string, _ int) *boxlayout.Box {
return accordionBox(&boxlayout.Box{Window: window, Weight: 1}) return accordionBox(&boxlayout.Box{Window: window, Weight: 1})
})...) })
} else { } else {
squashedHeight := 1 squashedHeight := 1
if height >= 21 { if height >= 21 {

View file

@ -83,25 +83,35 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container]
return sortContainers(a, b, gui.Config.UserConfig.Gui.LegacySortContainers) return sortContainers(a, b, gui.Config.UserConfig.Gui.LegacySortContainers)
}, },
Filter: func(container *commands.Container) bool { 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" { if !gui.State.ShowExitedContainers && container.Container.State == "exited" {
return false return false
} }
// Filter by selected project. Containers with no project (truly // When project-scoped, apply project and standalone filtering.
// standalone, not from any compose project) are always shown. // Otherwise all containers are shown in a flat list regardless
selectedProject := gui.getSelectedProjectName() // of which compose project they belong to.
if selectedProject == "" { if gui.DockerCommand.IsProjectScoped() {
selectedProject = gui.DockerCommand.LocalProjectName // This check must be inside the IsProjectScoped guard: when
} // not project-scoped, services are still derived from container
if selectedProject != "" && container.ProjectName != "" && container.ProjectName != selectedProject { // labels, so compose-managed containers from other projects
return false // 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()
if selectedProject == "" {
selectedProject = gui.DockerCommand.LocalProjectName
}
if selectedProject != "" && container.ProjectName != "" && container.ProjectName != selectedProject {
return false
}
} }
return true return true

View file

@ -459,7 +459,7 @@ func (gui *Gui) ShouldRefresh(key string) bool {
} }
func (gui *Gui) initiallyFocusedViewName() string { func (gui *Gui) initiallyFocusedViewName() string {
if gui.DockerCommand.InDockerComposeProject { if gui.DockerCommand.IsProjectScoped() {
return "services" return "services"
} }
return "containers" return "containers"

View file

@ -58,6 +58,9 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] {
// containers to show only those belonging to the selected project. // containers to show only those belonging to the selected project.
return gui.renderContainersAndServices() return gui.renderContainersAndServices()
}, },
Hide: func() bool {
return !gui.DockerCommand.IsProjectScoped()
},
} }
} }
@ -91,18 +94,19 @@ func (gui *Gui) refreshProject() error {
} }
// getDiscoveredProjects returns all docker compose projects by examining container labels. // getDiscoveredProjects returns all docker compose projects by examining container labels.
// The local project (from docker-compose.yml in the current directory) is included if // The local project (from docker-compose.yml in the current directory, or from -p) is
// it has running containers or if InDockerComposeProject is true. // 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 { func (gui *Gui) getDiscoveredProjects() []*commands.Project {
containers := gui.Panels.Containers.List.GetAllItems() containers := gui.Panels.Containers.List.GetAllItems()
projectNames := gui.DockerCommand.GetProjectNames(containers) projectNames := gui.DockerCommand.GetProjectNames(containers)
// If we're in a docker compose project but it has no running containers, // If we're scoped to a project but it has no running containers, still
// still include it. We don't fall back to the directory name here to avoid // include it. We don't fall back to the directory name here to avoid
// briefly flashing the wrong project name on startup. // briefly flashing the wrong project name on startup.
localName := gui.DockerCommand.LocalProjectName localName := gui.DockerCommand.LocalProjectName
if gui.DockerCommand.InDockerComposeProject && localName != "" { if gui.DockerCommand.IsProjectScoped() && localName != "" {
found := false found := false
for _, name := range projectNames { for _, name := range projectNames {
if name == localName { if name == localName {
@ -187,6 +191,11 @@ func (gui *Gui) renderAllLogs(project *commands.Project) tasks.TaskFunc {
} }
func (gui *Gui) renderDockerComposeConfig(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 { if project != nil && project.Name != gui.DockerCommand.LocalProjectName {
return gui.NewSimpleRenderStringTask(func() string { return gui.NewSimpleRenderStringTask(func() string {
return "Compose config is not available for non-local projects" return "Compose config is not available for non-local projects"

View file

@ -90,8 +90,7 @@ func (gui *Gui) getServicesPanel() *panels.SideListPanel[*commands.Service] {
return presentation.GetServiceDisplayStrings(&gui.Config.UserConfig.Gui, service) return presentation.GetServiceDisplayStrings(&gui.Config.UserConfig.Gui, service)
}, },
Hide: func() bool { Hide: func() bool {
// Show services panel if there are any compose projects (local or discovered) return !gui.DockerCommand.IsProjectScoped()
return !gui.DockerCommand.InDockerComposeProject && len(gui.Panels.Services.List.GetAllItems()) == 0
}, },
} }
} }

View file

@ -130,7 +130,7 @@ func (gui *Gui) createAllViews() error {
gui.Views.Containers.Highlight = true gui.Views.Containers.Highlight = true
gui.Views.Containers.SelBgColor = selectedLineBgColor 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 gui.Views.Containers.Title = gui.Tr.ContainersTitle
} else { } else {
gui.Views.Containers.Title = gui.Tr.StandaloneContainersTitle gui.Views.Containers.Title = gui.Tr.StandaloneContainersTitle