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) <noreply@anthropic.com>
This commit is contained in:
Tim Abell 2026-05-19 14:08:17 +01:00
parent 9d1c7bcbd8
commit 9545538106
No known key found for this signature in database
GPG key ID: E6545E4A512C39FA
11 changed files with 502 additions and 21 deletions

View file

@ -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 <name>` 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:

View file

@ -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 <LocalProjectName> --profile <name>`. 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 <name>`.
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

View file

@ -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 <LocalProjectName>` and `--profile <name>` 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")
}

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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 <name>` 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 }}",

View file

@ -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()

View file

@ -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}
}

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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 <name>` 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?",