From c0ff1bccfdaca77626077eecee95172b8f8a8b89 Mon Sep 17 00:00:00 2001 From: Tim Abell Date: Tue, 19 May 2026 12:27:45 +0100 Subject: [PATCH 1/5] Add plan for profile support This Architecture Decision Record was generated following trying out the stale PR that attempted to add profiles that didn't work for me and was conflicted with latest master. The ADR/plan was generated by discussion with claude code. Seems like a reasonable approach to me. --- .../0001-docker-compose-profile-support.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/adr/0001-docker-compose-profile-support.md diff --git a/docs/adr/0001-docker-compose-profile-support.md b/docs/adr/0001-docker-compose-profile-support.md new file mode 100644 index 00000000..3d8e9770 --- /dev/null +++ b/docs/adr/0001-docker-compose-profile-support.md @@ -0,0 +1,90 @@ +# 1. Docker-compose profile support + +## Status + +Proposed. + +## Context + +[Issue #423](https://github.com/jesseduffield/lazydocker/issues/423) requests that lazydocker understand docker-compose [profiles](https://docs.docker.com/compose/profiles/). Today it shows every service in the project, with no way to scope to a specific profile and no way to invoke profile-aware `up`/`down`/`restart`. + +A community PR — [#638](https://github.com/jesseduffield/lazydocker/pull/638), branch `saravanabalagi/feat/docker_profiles` — attempted this in March 2025 and has sat open since. Two problems with picking it up: + +1. **Master moved on substantially.** Since the PR was authored, master has gained multi-project discovery from container labels (`com.docker.compose.project`), a project-filter on the services and containers panels, a `Hide` on the project panel keyed off `IsProjectScoped()`, and a `-p` flag injection in `NewCommandObject`. The PR's structural changes overlap with those refactors; merging or rebasing produces large, gnarly conflicts in `project_panel.go`. +2. **The PR has a latent bug.** It adds a `CheckDockerComposeProfiles` template that uses a shell pipeline (`... | grep -q ...; echo $?`), but `OSCommand.RunCommand` parses commands with `str.ToArgv` and exec's directly — no shell. So the precheck always fails, `HasProfiles` stays false, and the UI never shows profile rows even though the rest of the feature is wired up. Anyone testing the branch sees "nothing happens". + +The cleanest path is to redesign on master rather than rescue the PR. Master's `getDiscoveredProjects()` already gives us a list-of-rows model in the project panel; profiles slot in as additional rows of the same `*commands.Project` type. And master's `NewCommandObject` already injects `-p ` from a `Project`/`Service` field — we can extend the same mechanism to inject `--profile `, removing the need for the PR's separate `UpProfile`/`DownProfile`/... template family. + +## Decision + +Implement profile support as an additive change on master, with the following architectural choices: + +### Profile is a flag on `Project`, not a new type + +Add `IsProfile bool` to `pkg/commands/project.go`'s `Project` struct. Profiles render as pseudo-projects in the panel. This avoids a generic-type split across the `SideListPanel[*commands.Project]`, `CommandObject`, presentation, services and containers panels — all of which already pass `*Project` around. + +### Reuse existing command templates; inject `--profile X` via `NewCommandObject` + +Add a `Profile string` field to `CommandObject` and have `NewCommandObject` append `--profile X` (in addition to the existing `-p ` for the local project) when a profile context is set. This means the existing `Up`, `Down`, `DownWithVolumes`, `AllLogs`, `DockerComposeConfig` templates work unchanged for profile invocations — no `UpProfile` / `DownProfile` / `RestartProfile` / `AllLogsProfile` template family. The single new template is `Restart` (master doesn't have a project-level restart at all today). + +This is the key reason the change is small. The original PR added ~12 new templates and ~12 corresponding i18n strings; this design needs one new template and a handful of i18n strings (for confirmation/status messages on profile actions). + +### Discovery via shelling out, cached for the session + +Add `GetProfiles()` and `GetProfileServices(profile)` methods on `DockerCommand` that shell out to `docker compose config --profiles` and `docker compose --profile X config --services` respectively. Cache results on the `DockerCommand` struct under a mutex; never invalidate during a session. Compose files rarely change mid-session, and `GetServices()` already has the same property — users restart lazydocker to pick up changes. + +**No precheck.** The PR's `CheckDockerComposeProfiles` template (which was the bug source) is unnecessary — we just enumerate. Empty result → no profile rows. This also means no shell-pipe escape hatch is needed; everything stays compatible with `OSCommand.RunCommand`'s argv-exec model. + +### Soft-fail discovery + +If `GetProfiles()` errors (e.g. `docker compose` is missing, compose v1 too old), log once and cache empty. No popup, no warning row. The UI degrades to "no profile rows" — same UX as a compose project with no profiles declared. This matches master's existing `GetServices()` behaviour. + +### Selecting a profile filters the services and containers panels + +Master's project filter on `services_panel.go` and `containers_panel.go` keeps items where `ProjectName == selectedProject`. Add a profile branch: when a profile is selected, keep items where `ProjectName == LocalProjectName` AND `ServiceName ∈ GetProfileServices(profile)`. The set returned by `docker compose --profile X config --services` includes the profile's services plus the default (no-profile) services — that's docker's own semantics and matches what users expect ("show me what would be running if I activated this profile"). + +### Profiles render after projects in the panel + +Sort callback returns non-profiles first, then alphabetical within each group. No per-project grouping (profiles always belong to the local project, of which there is exactly one). Selection restore keys on the `(Name, IsProfile)` tuple so a profile that happens to share a project's name doesn't cross-select. + +### Visual: cyan "profile" prefix label + +Mirrors the existing two-column row layout. Re-uses `utils.ColoredString(..., color.FgCyan)`. + +### English-only i18n initially + +Add new strings to `pkg/i18n/english.go`. If `go build ./...` shows other locales fail (they shouldn't — they inherit empty strings for new fields by default), backfill defaults; otherwise leave localization for a follow-up. + +### Scope limits + +- No new keybinding for project/profile restart in this PR — leave for maintainer discussion. The handler exists; binding it is one line. +- No profile-aware custom service/container commands. A service belongs to a specific project, not a profile; threading profile context into per-service custom commands would change semantics surprisingly. +- No cache invalidation on compose-file change. Defer until requested. + +## Consequences + +### Positive + +- **Small diff.** ~80-100 lines added across 5-6 files, vs. the PR's ~350 lines. Easier review, easier maintenance. +- **No new template family.** `Up`/`Down`/`Restart`/`AllLogs`/`DockerComposeConfig` work unchanged for profile invocations because `--profile X` is injected at the `NewCommandObject` layer. Users who've customized these templates get profile support for free. +- **No latent shell-pipe bug.** No precheck template; no shell-metacharacter dependencies in `OSCommand.RunCommand`. +- **Slots into master's existing architecture.** Profiles are just more rows; selection still flows through `OnSelect`; filter still flows through the existing `Filter` callback. No new panel, no new lifecycle. +- **PR-able cleanly upstream.** Additive across well-bounded extension points. + +### Negative / risks + +- **Re-shells `docker compose config` once per session.** Both `--profiles` (once) and `--profile X --services` (once per profile selected). Both can be slow on large compose files. Mitigated by single-call caching. +- **`Restart` template is new.** Users with a heavily customized `~/.config/lazydocker/config.yml` that wipes defaults will need to add it. Default value is `"{{ .DockerCompose }} restart"` — boring and obvious. +- **Selection-restore tuple change.** If anything downstream of `refreshProject` assumes project names are unique within the panel, it breaks. Audit shows nothing relies on this today, but worth noting in PR review. +- **No per-profile cache invalidation.** If a user edits `compose.yml` to add a new profile mid-session, lazydocker won't see it until restart. Same constraint as `GetServices()`. +- **Profile-aware filtering issues an extra shell call per profile selection.** At 100ms refresh tick and cached results, this is one shell per profile per session — negligible. + +### Risks if rejected + +If upstream wants separate `UpProfile`/etc. templates for customization symmetry with the existing pattern, the design has to grow back toward the original PR's shape. Worth raising in PR discussion before sinking time into the larger version. + +## References + +- Issue: [jesseduffield/lazydocker#423](https://github.com/jesseduffield/lazydocker/issues/423) +- Prior PR (stale): [jesseduffield/lazydocker#638](https://github.com/jesseduffield/lazydocker/pull/638) +- Implementation plan: see this repo's branch and corresponding PR. From 9d1c7bcbd82a3a1e59b5de286f389c5a1cdda278 Mon Sep 17 00:00:00 2001 From: Tim Abell Date: Tue, 19 May 2026 14:48:21 +0100 Subject: [PATCH 2/5] Set mise go version to 1.26.3 Latest golang from https://mise.jdx.dev/ --- mise.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 mise.toml diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..b1b1cc4e --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.26.3" From 9545538106db6e8d1bb263b63699c689131d87b0 Mon Sep 17 00:00:00 2001 From: Tim Abell Date: Tue, 19 May 2026 14:08:17 +0100 Subject: [PATCH 3/5] Add docker-compose profile support Implements the design captured in docs/adr/0001-docker-compose-profile-support.md (jesseduffield/lazydocker#423). Profiles render as pseudo-projects in the project panel; selecting one filters the services and containers panels to the services that activate with that profile, and Up/Down/Restart on the selection use docker compose --profile X invocations. Key choices: - Profile is a flag on Project (IsProfile bool), not a new type. Avoids a generic-type split across the SideListPanel and CommandObject. - Reuse existing command templates (Up, Down, DownWithVolumes, Restart, AllLogs, DockerComposeConfig). NewCommandObject appends --profile X when a profile context is set, so no separate UpProfile/DownProfile/... family is needed. Adds a new Restart template (master had no project-level restart at all). - Discovery via 'docker compose config --profiles' and 'docker compose --profile X config --services', cached on DockerCommand for the session. Soft-fails on error (logs once, caches empty) so the UI degrades to "no profile rows" rather than popping an error. - Per-profile service filtering uses the local project and the profile's service set (default services + profile-X services, per docker semantics). - No new keybinding for project/profile restart in this PR; handler exists for follow-up discussion. - New unit tests cover NewCommandObject --profile injection, GetProfiles caching, GetProfileServices caching, and the soft-fail path. fixes: https://github.com/jesseduffield/lazydocker/issues/423 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/Config.md | 15 +++ pkg/commands/docker.go | 127 ++++++++++++++++++++- pkg/commands/docker_test.go | 137 +++++++++++++++++++++++ pkg/commands/docker_test_helpers_test.go | 21 ++++ pkg/commands/project.go | 5 + pkg/config/app_config.go | 6 + pkg/gui/containers_panel.go | 19 ++++ pkg/gui/presentation/projects.go | 9 +- pkg/gui/project_panel.go | 68 ++++++++++- pkg/gui/services_panel.go | 90 +++++++++++++-- pkg/i18n/english.go | 26 +++++ 11 files changed, 502 insertions(+), 21 deletions(-) create mode 100644 pkg/commands/docker_test_helpers_test.go diff --git a/docs/Config.md b/docs/Config.md index d386ff31..a33f3008 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -70,6 +70,7 @@ commandTemplates: up: '{{ .DockerCompose }} up -d' down: '{{ .DockerCompose }} down' downWithVolumes: '{{ .DockerCompose }} down --volumes' + restart: '{{ .DockerCompose }} restart' upService: '{{ .DockerCompose }} up -d {{ .Service.Name }}' startService: '{{ .DockerCompose }} start {{ .Service.Name }}' stopService: '{{ .DockerCompose }} stop {{ .Service.Name }}' @@ -95,6 +96,20 @@ stats: color: green ``` +## Docker Compose profiles + +When a docker-compose profile is selected in the project panel, the project-scoped +templates (`up`, `down`, `downWithVolumes`, `restart`, `allLogs`, `viewAlLogs`, +`dockerComposeConfig`) automatically have `--profile ` appended via the +`{{ .DockerCompose }}` substitution. There are no separate profile templates +to configure — any custom override of those templates will pick up the profile +flag for free. + +Selecting a profile row in the project panel also filters the services and +containers panels to the services that activate with that profile (default +services plus profile-tagged services, matching `docker compose --profile X +config --services`). + ## To see what all of the config options mean, and what other options you can set, see [here](https://godoc.org/github.com/jesseduffield/lazydocker/pkg/config) ## Color Attributes: diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index b055441a..1504792b 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -47,6 +47,19 @@ type DockerCommand struct { ContainerMutex deadlock.Mutex ServiceMutex deadlock.Mutex + // Profile discovery cache. Populated lazily; never invalidated within a + // session — compose file changes are rare and the user can restart + // lazydocker. Mirrors the existing GetServices() pattern. + profileMutex deadlock.Mutex + profilesCached bool + profiles []string + profileSvcs map[string][]string + + // runOutput is an injection seam for tests. Production code leaves it + // nil and falls through to OSCommand.RunCommandWithOutput. Tests can + // set this to return canned output without spinning up a real shell. + runOutput func(string) (string, error) + Closers []io.Closer } @@ -66,6 +79,7 @@ type CommandObject struct { Volume *Volume Network *Network Project *Project + Profile string } // NewCommandObject takes a command object and returns a default command object with the passed command object merged in @@ -77,10 +91,25 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject { // 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) - } else if obj.Project != nil && obj.Project.Name != "" { + } else if obj.Project != nil && obj.Project.Name != "" && !obj.Project.IsProfile { defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, obj.Project.Name) } + // Profile pseudo-projects always belong to the local project, so emit + // `-p --profile `. Allow the caller to supply + // the profile name either via the explicit Profile field or via a + // Project{IsProfile: true} pseudo-project. + profile := obj.Profile + if profile == "" && obj.Project != nil && obj.Project.IsProfile { + profile = obj.Project.Name + } + if profile != "" { + if c.LocalProjectName != "" && !strings.Contains(defaultObj.DockerCompose, " -p ") { + defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, c.LocalProjectName) + } + defaultObj.DockerCompose = fmt.Sprintf("%s --profile %s", defaultObj.DockerCompose, profile) + } + return defaultObj } @@ -490,12 +519,14 @@ func (c *DockerCommand) SetContainerDetails(containers []*Container) { wg.Wait() } -// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose -func (c *DockerCommand) ViewAllLogs(project *Project) (*exec.Cmd, error) { +// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose. +// The caller supplies a CommandObject so it can request a profile-scoped view +// (`{Profile: name}`) or a plain project view (`{Project: project}`). +func (c *DockerCommand) ViewAllLogs(obj CommandObject) (*exec.Cmd, error) { cmd := c.OSCommand.ExecutableFromString( utils.ApplyTemplate( c.OSCommand.Config.UserConfig.CommandTemplates.ViewAllLogs, - c.NewCommandObject(CommandObject{Project: project}), + c.NewCommandObject(obj), ), ) @@ -523,6 +554,94 @@ func (c *DockerCommand) DockerComposeConfigForProject(project *Project) string { return output } +// DockerComposeConfigForProfile renders the resolved compose YAML with a +// specific profile activated. The existing DockerComposeConfig template is +// reused; NewCommandObject appends `--profile `. +func (c *DockerCommand) DockerComposeConfigForProfile(profile string) string { + output, err := c.OSCommand.RunCommandWithOutput( + utils.ApplyTemplate( + c.OSCommand.Config.UserConfig.CommandTemplates.DockerComposeConfig, + c.NewCommandObject(CommandObject{Profile: profile}), + ), + ) + if err != nil { + output = err.Error() + } + return output +} + +// runCmdOutput is the injection seam used by the profile-discovery methods. +func (c *DockerCommand) runCmdOutput(command string) (string, error) { + if c.runOutput != nil { + return c.runOutput(command) + } + return c.OSCommand.RunCommandWithOutput(command) +} + +// GetProfiles returns the docker-compose profiles declared in the local +// compose project. Cached per session. Returns nil when not in a compose +// project. Soft-fails: an error from the underlying compose call is logged +// once and the cache is marked populated with an empty list, so the UI +// degrades to "no profile rows". +func (c *DockerCommand) GetProfiles() ([]string, error) { + c.profileMutex.Lock() + defer c.profileMutex.Unlock() + if c.profilesCached { + return c.profiles, nil + } + c.profilesCached = true + if !c.InDockerComposeProject { + return nil, nil + } + composeCmd := c.Config.UserConfig.CommandTemplates.DockerCompose + output, err := c.runCmdOutput(fmt.Sprintf("%s config --profiles", composeCmd)) + if err != nil { + c.Log.Warn("Failed to list compose profiles: " + err.Error()) + return nil, nil + } + seen := map[string]bool{} + for _, line := range utils.SplitLines(strings.TrimSpace(output)) { + line = strings.TrimSpace(line) + if line == "" || seen[line] { + continue + } + seen[line] = true + c.profiles = append(c.profiles, line) + } + sort.Strings(c.profiles) + return c.profiles, nil +} + +// GetProfileServices returns the service names that activate when the given +// profile is enabled (per docker compose semantics: default services + that +// profile's services). Cached per profile for the session. +func (c *DockerCommand) GetProfileServices(profile string) ([]string, error) { + c.profileMutex.Lock() + defer c.profileMutex.Unlock() + if c.profileSvcs == nil { + c.profileSvcs = map[string][]string{} + } + if svcs, ok := c.profileSvcs[profile]; ok { + return svcs, nil + } + composeCmd := c.Config.UserConfig.CommandTemplates.DockerCompose + output, err := c.runCmdOutput(fmt.Sprintf("%s --profile %s config --services", composeCmd, profile)) + if err != nil { + // Cache the empty slice so we don't re-shell every refresh tick. + c.profileSvcs[profile] = []string{} + return nil, err + } + lines := []string{} + for _, line := range utils.SplitLines(strings.TrimSpace(output)) { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + sort.Strings(lines) + c.profileSvcs[profile] = lines + return lines, nil +} + // determineDockerHost tries to the determine the docker host that we should connect to // in the following order of decreasing precedence: // - value of "DOCKER_HOST" environment variable diff --git a/pkg/commands/docker_test.go b/pkg/commands/docker_test.go index db45a06f..c06efd6c 100644 --- a/pkg/commands/docker_test.go +++ b/pkg/commands/docker_test.go @@ -1,6 +1,7 @@ package commands import ( + "errors" "os" "testing" @@ -89,3 +90,139 @@ func TestIsProjectScoped(t *testing.T) { }) } } + +// TestNewCommandObjectProfileInjection verifies that NewCommandObject appends +// `-p ` and `--profile ` correctly for the various +// ways a caller can identify a profile, and that the existing project/service +// `-p` injection isn't disturbed by the new code paths. +func TestNewCommandObjectProfileInjection(t *testing.T) { + cases := []struct { + name string + localProjectName string + obj CommandObject + want string + }{ + { + name: "no project, no profile", + obj: CommandObject{}, + want: "docker compose", + }, + { + name: "profile via Profile field with LocalProjectName", + localProjectName: "myapp", + obj: CommandObject{Profile: "dev"}, + want: "docker compose -p myapp --profile dev", + }, + { + name: "profile via Project pseudo-project with LocalProjectName", + localProjectName: "myapp", + obj: CommandObject{Project: &Project{Name: "dev", IsProfile: true}}, + want: "docker compose -p myapp --profile dev", + }, + { + name: "non-profile Project still gets -p with its own name", + obj: CommandObject{Project: &Project{Name: "other"}}, + want: "docker compose -p other", + }, + { + name: "Service supplies -p; profile only adds --profile", + obj: CommandObject{ + Service: &Service{ProjectName: "svcproj"}, + Profile: "dev", + }, + want: "docker compose -p svcproj --profile dev", + }, + { + name: "profile without LocalProjectName emits --profile only", + localProjectName: "", + obj: CommandObject{Profile: "dev"}, + want: "docker compose --profile dev", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := newTestDockerCommand(nil) + c.LocalProjectName = tc.localProjectName + got := c.NewCommandObject(tc.obj).DockerCompose + assert.Equal(t, tc.want, got) + }) + } +} + +// TestGetProfilesCaching exercises the per-session cache: the underlying +// command is invoked exactly once even across repeated calls, output is +// split/sorted/deduped, and the !InDockerComposeProject path short-circuits. +func TestGetProfilesCaching(t *testing.T) { + t.Run("caches across calls, dedupes and sorts", func(t *testing.T) { + calls := 0 + c := newTestDockerCommand(func(cmd string) (string, error) { + calls++ + assert.Equal(t, "docker compose config --profiles", cmd) + return "regulator\nobligations\nregulator\n", nil + }) + got1, err := c.GetProfiles() + assert.NoError(t, err) + assert.Equal(t, []string{"obligations", "regulator"}, got1) + got2, err := c.GetProfiles() + assert.NoError(t, err) + assert.Equal(t, got1, got2) + assert.Equal(t, 1, calls) + }) + t.Run("short-circuits when not in compose project", func(t *testing.T) { + calls := 0 + c := newTestDockerCommand(func(cmd string) (string, error) { + calls++ + return "", nil + }) + c.InDockerComposeProject = false + got, err := c.GetProfiles() + assert.NoError(t, err) + assert.Nil(t, got) + assert.Equal(t, 0, calls) + }) +} + +// TestGetProfileServicesCaching ensures the per-profile cache invokes the +// underlying command once per distinct profile, not once per call. +func TestGetProfileServicesCaching(t *testing.T) { + calls := 0 + c := newTestDockerCommand(func(cmd string) (string, error) { + calls++ + switch cmd { + case "docker compose --profile a config --services": + return "svc-a1\nsvc-a2\n", nil + case "docker compose --profile b config --services": + return "svc-b\n", nil + } + t.Fatalf("unexpected command: %q", cmd) + return "", nil + }) + a1, err := c.GetProfileServices("a") + assert.NoError(t, err) + assert.Equal(t, []string{"svc-a1", "svc-a2"}, a1) + a2, err := c.GetProfileServices("a") + assert.NoError(t, err) + assert.Equal(t, a1, a2) + b1, err := c.GetProfileServices("b") + assert.NoError(t, err) + assert.Equal(t, []string{"svc-b"}, b1) + assert.Equal(t, 2, calls, "one call per distinct profile, cached thereafter") +} + +// TestGetProfilesSoftFailsOnError verifies that errors from the compose +// shellout become a logged warning + empty cache rather than propagating +// to the UI, and that a subsequent call doesn't retry. +func TestGetProfilesSoftFailsOnError(t *testing.T) { + calls := 0 + c := newTestDockerCommand(func(cmd string) (string, error) { + calls++ + return "", errors.New("compose not installed") + }) + got, err := c.GetProfiles() + assert.NoError(t, err, "errors are soft-failed; UI sees empty list") + assert.Nil(t, got) + got2, err := c.GetProfiles() + assert.NoError(t, err) + assert.Equal(t, got, got2) + assert.Equal(t, 1, calls, "error result is cached; no retry") +} diff --git a/pkg/commands/docker_test_helpers_test.go b/pkg/commands/docker_test_helpers_test.go new file mode 100644 index 00000000..2ea0b6a8 --- /dev/null +++ b/pkg/commands/docker_test_helpers_test.go @@ -0,0 +1,21 @@ +package commands + +import ( + "github.com/jesseduffield/lazydocker/pkg/config" + "github.com/sirupsen/logrus" +) + +// newTestDockerCommand assembles a DockerCommand suitable for unit-testing the +// profile-discovery and command-templating logic in isolation. It populates +// just the fields those code paths touch: Config (with default user templates), +// Log, and the runOutput injection seam used in place of the real OSCommand +// shellout. +func newTestDockerCommand(runOutput func(string) (string, error)) *DockerCommand { + userCfg := config.GetDefaultConfig() + return &DockerCommand{ + Log: logrus.NewEntry(logrus.New()), + Config: &config.AppConfig{UserConfig: &userCfg}, + InDockerComposeProject: true, + runOutput: runOutput, + } +} diff --git a/pkg/commands/project.go b/pkg/commands/project.go index 529ccfa7..205dfa5d 100644 --- a/pkg/commands/project.go +++ b/pkg/commands/project.go @@ -2,4 +2,9 @@ package commands type Project struct { Name string + // IsProfile marks a row in the project panel as a docker-compose profile + // pseudo-project. Profiles live within the local compose project; selecting + // one filters the services panel to that profile and routes Up/Down/Restart + // through the --profile flag. + IsProfile bool } diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 0ebc1796..257242e9 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -166,6 +166,11 @@ type CommandTemplatesConfig struct { // downs and removes volumes DownWithVolumes string `yaml:"downWithVolumes,omitempty"` + // Restart restarts the whole project. When a profile is selected in the + // project panel, `--profile ` is appended automatically via the + // CommandObject injection in NewCommandObject. + Restart string `yaml:"restart,omitempty"` + // DockerCompose is for your docker-compose command. You may want to combine a // few different docker-compose.yml files together, in which case you can set // this to "docker compose -f foo/docker-compose.yml -f @@ -391,6 +396,7 @@ func GetDefaultConfig() UserConfig { Up: "{{ .DockerCompose }} up -d", Down: "{{ .DockerCompose }} down", DownWithVolumes: "{{ .DockerCompose }} down --volumes", + Restart: "{{ .DockerCompose }} restart", UpService: "{{ .DockerCompose }} up -d {{ .Service.Name }}", RebuildService: "{{ .DockerCompose }} up -d --build {{ .Service.Name }}", RecreateService: "{{ .DockerCompose }} up -d --force-recreate {{ .Service.Name }}", diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index a638ee29..cba1d13e 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -103,6 +103,25 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container] return false } + // Profile selected? scope to the local project's containers + // whose service is in the profile's service set. Truly + // standalone containers (no compose project) are shown. + if profile := gui.getSelectedProfile(); profile != "" { + if container.ProjectName == "" { + return true + } + if container.ProjectName != gui.DockerCommand.LocalProjectName { + return false + } + svcs, _ := gui.DockerCommand.GetProfileServices(profile) + for _, n := range svcs { + if container.ServiceName == n { + return true + } + } + return false + } + // Filter by selected project. Containers with no project (truly // standalone, not from any compose project) are always shown. selectedProject := gui.getSelectedProjectName() diff --git a/pkg/gui/presentation/projects.go b/pkg/gui/presentation/projects.go index 44d396c9..3b0154b2 100644 --- a/pkg/gui/presentation/projects.go +++ b/pkg/gui/presentation/projects.go @@ -1,7 +1,14 @@ package presentation -import "github.com/jesseduffield/lazydocker/pkg/commands" +import ( + "github.com/fatih/color" + "github.com/jesseduffield/lazydocker/pkg/commands" + "github.com/jesseduffield/lazydocker/pkg/utils" +) func GetProjectDisplayStrings(project *commands.Project) []string { + if project.IsProfile { + return []string{utils.ColoredString("profile ", color.FgCyan) + project.Name} + } return []string{project.Name} } diff --git a/pkg/gui/project_panel.go b/pkg/gui/project_panel.go index e41f7b8f..d4164d04 100644 --- a/pkg/gui/project_panel.go +++ b/pkg/gui/project_panel.go @@ -50,6 +50,10 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] { Gui: gui.intoInterface(), Sort: func(a *commands.Project, b *commands.Project) bool { + // Projects first (alphabetical), then profiles (alphabetical). + if a.IsProfile != b.IsProfile { + return !a.IsProfile + } return a.Name < b.Name }, GetTableCells: presentation.GetProjectDisplayStrings, @@ -69,7 +73,10 @@ func (gui *Gui) refreshProject() error { // Preserve the current selection across refreshes. On the first refresh, // select the project specified via -p flag, or fall back to the local project. + // We match on (Name, IsProfile) so a profile that shares a project's name + // doesn't cross-select with the project row. selectedName := gui.getSelectedProjectName() + selectedIsProfile := gui.isSelectedProjectAProfile() if selectedName == "" { if gui.Config.ProjectName != "" { selectedName = gui.Config.ProjectName @@ -82,7 +89,7 @@ func (gui *Gui) refreshProject() error { if selectedName != "" { for i, p := range gui.Panels.Projects.List.GetItems() { - if p.Name == selectedName { + if p.Name == selectedName && p.IsProfile == selectedIsProfile { gui.Panels.Projects.SetSelectedLineIdx(i) gui.Panels.Projects.Refocus() break @@ -119,9 +126,19 @@ func (gui *Gui) getDiscoveredProjects() []*commands.Project { } } - projects := make([]*commands.Project, len(projectNames)) - for i, name := range projectNames { - projects[i] = &commands.Project{Name: name} + projects := make([]*commands.Project, 0, len(projectNames)) + for _, name := range projectNames { + projects = append(projects, &commands.Project{Name: name}) + } + + // Append profile pseudo-projects for the local compose project. The Sort + // callback above places these after all regular project rows. + if gui.DockerCommand.InDockerComposeProject { + if profileNames, _ := gui.DockerCommand.GetProfiles(); len(profileNames) > 0 { + for _, p := range profileNames { + projects = append(projects, &commands.Project{Name: p, IsProfile: true}) + } + } } return projects @@ -137,6 +154,28 @@ func (gui *Gui) getSelectedProjectName() string { return project.Name } +// isSelectedProjectAProfile reports whether the currently selected project-panel +// row is a profile pseudo-project. Used to disambiguate selection restore when +// a profile name happens to equal a project name. +func (gui *Gui) isSelectedProjectAProfile() bool { + project, err := gui.Panels.Projects.GetSelectedItem() + if err != nil { + return false + } + return project.IsProfile +} + +// getSelectedProfile returns the selected profile name, or "" if the current +// project-panel selection isn't a profile. Used by the services and containers +// filters to scope to a profile's service set. +func (gui *Gui) getSelectedProfile() string { + project, err := gui.Panels.Projects.GetSelectedItem() + if err != nil || project == nil || !project.IsProfile { + return "" + } + return project.Name +} + func (gui *Gui) renderCredits(_project *commands.Project) tasks.TaskFunc { return gui.NewSimpleRenderStringTask(func() string { return gui.creditsStr() }) } @@ -165,10 +204,16 @@ func (gui *Gui) renderAllLogs(project *commands.Project) tasks.TaskFunc { Func: func(ctx context.Context) { gui.clearMainView() + obj := commands.CommandObject{} + if project != nil && project.IsProfile { + obj.Profile = project.Name + } else { + obj.Project = project + } cmd := gui.OSCommand.RunCustomCommand( utils.ApplyTemplate( gui.Config.UserConfig.CommandTemplates.AllLogs, - gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}), + gui.DockerCommand.NewCommandObject(obj), ), ) @@ -196,6 +241,11 @@ func (gui *Gui) renderDockerComposeConfig(project *commands.Project) tasks.TaskF return "Compose config is only available when launched from a docker-compose project directory" }) } + if project != nil && project.IsProfile { + return gui.NewSimpleRenderStringTask(func() string { + return utils.ColoredYamlString(gui.DockerCommand.DockerComposeConfigForProfile(project.Name)) + }) + } if project != nil && project.Name != gui.DockerCommand.LocalProjectName { return gui.NewSimpleRenderStringTask(func() string { return "Compose config is not available for non-local projects" @@ -230,7 +280,13 @@ func lazydockerTitle() string { // handleViewAllLogs switches to a subprocess viewing all the logs from docker-compose func (gui *Gui) handleViewAllLogs(g *gocui.Gui, v *gocui.View) error { project, _ := gui.Panels.Projects.GetSelectedItem() - c, err := gui.DockerCommand.ViewAllLogs(project) + obj := commands.CommandObject{} + if project != nil && project.IsProfile { + obj.Profile = project.Name + } else { + obj.Project = project + } + c, err := gui.DockerCommand.ViewAllLogs(obj) if err != nil { return gui.createErrorPanel(err.Error()) } diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go index edc894e9..c699e75d 100644 --- a/pkg/gui/services_panel.go +++ b/pkg/gui/services_panel.go @@ -75,6 +75,22 @@ func (gui *Gui) getServicesPanel() *panels.SideListPanel[*commands.Service] { return a.Name < b.Name }, Filter: func(service *commands.Service) bool { + // Profile selected? scope to the local project AND to services + // declared in (or default-active for) that profile. Per docker + // compose semantics, `--profile X config --services` returns + // profile-X services plus default (no-profile) services. + if profile := gui.getSelectedProfile(); profile != "" { + if service.ProjectName != gui.DockerCommand.LocalProjectName { + return false + } + svcs, _ := gui.DockerCommand.GetProfileServices(profile) + for _, n := range svcs { + if n == service.Name { + return true + } + } + return false + } selectedProject := gui.getSelectedProjectName() if selectedProject == "" { // Before any project is selected (e.g. startup), default to @@ -335,16 +351,26 @@ func (gui *Gui) handleServiceRenderLogsToMain(g *gocui.Gui, v *gocui.View) error func (gui *Gui) handleProjectUp(g *gocui.Gui, v *gocui.View) error { project, _ := gui.Panels.Projects.GetSelectedItem() - if project != nil && project.Name != gui.DockerCommand.LocalProjectName { + if project != nil && !project.IsProfile && project.Name != gui.DockerCommand.LocalProjectName { return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService) } - return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmUpProject, func(g *gocui.Gui, v *gocui.View) error { + obj := commands.CommandObject{} + confirmMsg := gui.Tr.ConfirmUpProject + status := gui.Tr.UppingProjectStatus + if project != nil && project.IsProfile { + obj.Profile = project.Name + confirmMsg = gui.Tr.ConfirmUpProfile + status = gui.Tr.UppingProfileStatus + } else { + obj.Project = project + } + return gui.createConfirmationPanel(gui.Tr.Confirm, confirmMsg, func(g *gocui.Gui, v *gocui.View) error { cmdStr := utils.ApplyTemplate( gui.Config.UserConfig.CommandTemplates.Up, - gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}), + gui.DockerCommand.NewCommandObject(obj), ) - return gui.WithWaitingStatus(gui.Tr.UppingProjectStatus, func() error { + return gui.WithWaitingStatus(status, func() error { if err := gui.OSCommand.RunCommand(cmdStr); err != nil { return gui.createErrorPanel(err.Error()) } @@ -355,17 +381,25 @@ func (gui *Gui) handleProjectUp(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error { project, _ := gui.Panels.Projects.GetSelectedItem() - if project != nil && project.Name != gui.DockerCommand.LocalProjectName { + if project != nil && !project.IsProfile && project.Name != gui.DockerCommand.LocalProjectName { return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService) } + obj := commands.CommandObject{} + status := gui.Tr.DowningStatus + if project != nil && project.IsProfile { + obj.Profile = project.Name + status = gui.Tr.DowningProfileStatus + } else { + obj.Project = project + } downCommand := utils.ApplyTemplate( gui.Config.UserConfig.CommandTemplates.Down, - gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}), + gui.DockerCommand.NewCommandObject(obj), ) downWithVolumesCommand := utils.ApplyTemplate( gui.Config.UserConfig.CommandTemplates.DownWithVolumes, - gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}), + gui.DockerCommand.NewCommandObject(obj), ) options := []*commandOption{ @@ -373,7 +407,7 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error { description: gui.Tr.Down, command: downCommand, onPress: func() error { - return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error { + return gui.WithWaitingStatus(status, func() error { if err := gui.OSCommand.RunCommand(downCommand); err != nil { return gui.createErrorPanel(err.Error()) } @@ -385,7 +419,7 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error { description: gui.Tr.DownWithVolumes, command: downWithVolumesCommand, onPress: func() error { - return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error { + return gui.WithWaitingStatus(status, func() error { if err := gui.OSCommand.RunCommand(downWithVolumesCommand); err != nil { return gui.createErrorPanel(err.Error()) } @@ -408,6 +442,34 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error { }) } +// handleProjectRestart restarts the selected project (or the selected +// profile's services within the local project). Not currently bound to a key +// in keybindings.go — opt-in via custom-command config or a follow-up PR. +func (gui *Gui) handleProjectRestart(g *gocui.Gui, v *gocui.View) error { + project, _ := gui.Panels.Projects.GetSelectedItem() + if project != nil && !project.IsProfile && project.Name != gui.DockerCommand.LocalProjectName { + return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService) + } + obj := commands.CommandObject{} + status := gui.Tr.RestartingStatus + if project != nil && project.IsProfile { + obj.Profile = project.Name + status = gui.Tr.RestartingProfileStatus + } else { + obj.Project = project + } + cmdStr := utils.ApplyTemplate( + gui.Config.UserConfig.CommandTemplates.Restart, + gui.DockerCommand.NewCommandObject(obj), + ) + return gui.WithWaitingStatus(status, func() error { + if err := gui.OSCommand.RunCommand(cmdStr); err != nil { + return gui.createErrorPanel(err.Error()) + } + return nil + }) +} + func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error { service, err := gui.Panels.Services.GetSelectedItem() if err != nil { @@ -524,7 +586,15 @@ L: func (gui *Gui) handleServicesBulkCommand(g *gocui.Gui, v *gocui.View) error { project, _ := gui.Panels.Projects.GetSelectedItem() bulkCommands := gui.Config.UserConfig.BulkCommands.Services - commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}) + obj := commands.CommandObject{} + if project != nil && project.IsProfile { + // Profile selected: user-defined bulk commands that use + // {{ .DockerCompose }} get --profile X auto-injected. + obj.Profile = project.Name + } else { + obj.Project = project + } + commandObject := gui.DockerCommand.NewCommandObject(obj) return gui.createBulkCommandMenu(bulkCommands, commandObject) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d86b0c84..2188c555 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -67,6 +67,21 @@ type TranslationSet struct { ViewLogs string UpProject string DownProject string + RestartProject string + + // Profile-related strings for docker-compose profile pseudo-projects in + // the project panel. Selecting a profile and triggering Up/Down/Restart + // shells out with `--profile ` instead of bare project commands. + ProfileLabel string + UpProfile string + DownProfile string + RestartProfile string + ConfirmUpProfile string + ConfirmDownProfile string + ConfirmRestartProfile string + UppingProfileStatus string + DowningProfileStatus string + RestartingProfileStatus string ServicesTitle string ContainersTitle string StandaloneContainersTitle string @@ -198,6 +213,14 @@ func englishSet() TranslationSet { ViewLogs: "view logs", UpProject: "up project", DownProject: "down project", + RestartProject: "restart project", + ProfileLabel: "profile", + UpProfile: "up profile", + DownProfile: "down profile", + RestartProfile: "restart profile", + UppingProfileStatus: "upping profile", + DowningProfileStatus: "downing profile", + RestartingProfileStatus: "restarting profile", RemoveImage: "remove image", RemoveVolume: "remove volume", RemoveNetwork: "remove network", @@ -253,6 +276,9 @@ func englishSet() TranslationSet { ConfirmQuit: "Are you sure you want to quit?", ConfirmUpProject: "Are you sure you want to 'up' your docker compose project?", + ConfirmUpProfile: "Are you sure you want to 'up' this docker compose profile?", + ConfirmDownProfile: "Are you sure you want to 'down' this docker compose profile?", + ConfirmRestartProfile: "Are you sure you want to 'restart' this docker compose profile?", 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?", From fb6f7fd3805326646f6f21427db5b59f4151034d Mon Sep 17 00:00:00 2001 From: Tim Abell Date: Wed, 20 May 2026 11:44:19 +0100 Subject: [PATCH 4/5] Size project panel to fit its rows when unfocused Previously hard-coded to Size: 3 (one visible row) on the assumption there was only ever one project. With profile pseudo-projects you can have several rows, but they were hidden until you focused the panel. Sizes to Len()+2 (rows plus borders), clamped to [3, 8] so the single-project case is unchanged and a project with many profiles can't squeeze out the other side panels. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gui/arrangement.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pkg/gui/arrangement.go b/pkg/gui/arrangement.go index 9aceec70..18de4ae8 100644 --- a/pkg/gui/arrangement.go +++ b/pkg/gui/arrangement.go @@ -175,13 +175,22 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box { return defaultBox } - // The project panel is compact (Size: 3) when not focused, but expands - // 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). + // The project panel sizes to fit its rows when not focused (so profiles + // are visible without having to click in), and expands by weight when + // focused. Capped so a project with many profiles can't crowd out the + // other side panels. 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" { + const maxUnfocusedSize = 8 + size := gui.Panels.Projects.List.Len() + 2 + if size < 3 { + size = 3 + } else if size > maxUnfocusedSize { + size = maxUnfocusedSize + } projectBox := &boxlayout.Box{ Window: sideWindowNames[0], - Size: 3, + Size: size, } if currentWindow == sideWindowNames[0] { projectBox = &boxlayout.Box{ From 1f035a8d6d318c84e8d7ec47cb3d9ba3a1b5f961 Mon Sep 17 00:00:00 2001 From: Tim Abell Date: Wed, 20 May 2026 12:22:13 +0100 Subject: [PATCH 5/5] Stop project panel from over-expanding on focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focus previously switched the box from Size to Weight: 2, giving the panel ~2/7 of the side area regardless of how many rows it had. That also ignored Gui.ExpandFocusedSidePanel — every other side panel only expands on focus when accordion mode is on. Route the project box through the same accordionBox helper as the others. Stays fitted to content normally; expands on focus only when the user opted in. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gui/arrangement.go | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/pkg/gui/arrangement.go b/pkg/gui/arrangement.go index 18de4ae8..8ac01024 100644 --- a/pkg/gui/arrangement.go +++ b/pkg/gui/arrangement.go @@ -175,29 +175,25 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box { return defaultBox } - // The project panel sizes to fit its rows when not focused (so profiles - // are visible without having to click in), and expands by weight when - // focused. Capped so a project with many profiles can't crowd out the - // other side panels. This only applies when the project panel is - // actually visible (i.e. we are inside a compose project). + // The project panel sizes to fit its rows (so profiles are visible + // without having to click in). Capped so a project with many profiles + // can't crowd out the other side panels. Routed through accordionBox + // so it follows the same focus-expansion rule as the other panels + // (only expands on focus when Gui.ExpandFocusedSidePanel is set). + // 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" { - const maxUnfocusedSize = 8 + const maxProjectPanelSize = 8 size := gui.Panels.Projects.List.Len() + 2 if size < 3 { size = 3 - } else if size > maxUnfocusedSize { - size = maxUnfocusedSize + } else if size > maxProjectPanelSize { + size = maxProjectPanelSize } - projectBox := &boxlayout.Box{ + projectBox := accordionBox(&boxlayout.Box{ Window: sideWindowNames[0], Size: size, - } - if currentWindow == sideWindowNames[0] { - projectBox = &boxlayout.Box{ - Window: sideWindowNames[0], - Weight: 2, - } - } + }) return append([]*boxlayout.Box{ projectBox,