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?",