mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-22 23:21:03 +00:00
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:
parent
9d1c7bcbd8
commit
9545538106
11 changed files with 502 additions and 21 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
21
pkg/commands/docker_test_helpers_test.go
Normal file
21
pkg/commands/docker_test_helpers_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue