diff --git a/config/config.yml b/config/config.yml
new file mode 100644
index 00000000..e69de29b
diff --git a/docker-compose.yml b/docker-compose.yml
index c4c27c55..91d51cb2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,3 @@
-version: '3'
services:
lazydocker:
build:
diff --git a/docs/keybindings/Keybindings_de.md b/docs/keybindings/Keybindings_de.md
index 2d306a2d..dc9dd08e 100644
--- a/docs/keybindings/Keybindings_de.md
+++ b/docs/keybindings/Keybindings_de.md
@@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: fokussieren aufs Hauptpanel
[: vorheriges Tab
]: nächstes Tab
+ /: filter list
## Container
diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md
index 13470d1d..ae21c4dc 100644
--- a/docs/keybindings/Keybindings_en.md
+++ b/docs/keybindings/Keybindings_en.md
@@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: focus main panel
[: previous tab
]: next tab
+ /: filter list
## Containers
diff --git a/docs/keybindings/Keybindings_es.md b/docs/keybindings/Keybindings_es.md
index 145c7a22..6b4eb921 100644
--- a/docs/keybindings/Keybindings_es.md
+++ b/docs/keybindings/Keybindings_es.md
@@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: enfocar panel principal
[: anterior pestaña
]: siguiente pestaña
+ /: filtrar lista
## Contenedores
diff --git a/docs/keybindings/Keybindings_fr.md b/docs/keybindings/Keybindings_fr.md
index 260fe0bd..109078b2 100644
--- a/docs/keybindings/Keybindings_fr.md
+++ b/docs/keybindings/Keybindings_fr.md
@@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: focus panneau principal
[: onglet précédent
]: onglet suivant
+ /: filter list
## Conteneurs
diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md
index dca57f64..24477006 100644
--- a/docs/keybindings/Keybindings_nl.md
+++ b/docs/keybindings/Keybindings_nl.md
@@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: focus hoofdpaneel
[: vorige tab
]: volgende tab
+ /: filter list
## Containers
diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md
index c290fdc2..2f4cb93f 100644
--- a/docs/keybindings/Keybindings_pl.md
+++ b/docs/keybindings/Keybindings_pl.md
@@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: skup na głównym panelu
[: poprzednia zakładka
]: następna zakładka
+ /: filter list
## Kontenery
diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md
index 73544cc9..32a557b5 100644
--- a/docs/keybindings/Keybindings_pt.md
+++ b/docs/keybindings/Keybindings_pt.md
@@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: focar no painel principal
[: aba anterior
]: próxima aba
+ /: filtrar lista
## Contêineres
diff --git a/docs/keybindings/Keybindings_tr.md b/docs/keybindings/Keybindings_tr.md
index 787d7d15..3192a7c2 100644
--- a/docs/keybindings/Keybindings_tr.md
+++ b/docs/keybindings/Keybindings_tr.md
@@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: ana panele odaklan
[: önceki sekme
]: sonraki sekme
+ /: filter list
## Konteynerler
diff --git a/docs/keybindings/Keybindings_zh.md b/docs/keybindings/Keybindings_zh.md
index 0e47a44e..0c75fbcb 100644
--- a/docs/keybindings/Keybindings_zh.md
+++ b/docs/keybindings/Keybindings_zh.md
@@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: 聚焦主面板
[: 上一个选项卡
]: 下一个选项卡
+ /: 过滤列表
## 容器
diff --git a/main.go b/main.go
index 09f37180..e7679b10 100644
--- a/main.go
+++ b/main.go
@@ -29,6 +29,7 @@ var (
configFlag = false
debuggingFlag = false
composeFiles []string
+ projectName string
)
func main() {
@@ -51,6 +52,7 @@ func main() {
flaggy.Bool(&configFlag, "c", "config", "Print the current default config")
flaggy.Bool(&debuggingFlag, "d", "debug", "a boolean")
flaggy.StringSlice(&composeFiles, "f", "file", "Specify alternate compose files")
+ flaggy.String(&projectName, "p", "project", "Specify a docker compose project name")
flaggy.SetVersion(info)
flaggy.Parse()
@@ -71,7 +73,7 @@ func main() {
log.Fatal(err.Error())
}
- appConfig, err := config.NewAppConfig("lazydocker", version, commit, date, buildSource, debuggingFlag, composeFiles, projectDir)
+ appConfig, err := config.NewAppConfig("lazydocker", version, commit, date, buildSource, debuggingFlag, composeFiles, projectDir, projectName)
if err != nil {
log.Fatal(err.Error())
}
diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go
index 76e9875d..f837da62 100644
--- a/pkg/cheatsheet/generate.go
+++ b/pkg/cheatsheet/generate.go
@@ -33,7 +33,7 @@ func Generate() {
}
func generateAtDir(dir string) {
- mConfig, err := config.NewAppConfig("lazydocker", "", "", "", "", true, nil, "")
+ mConfig, err := config.NewAppConfig("lazydocker", "", "", "", "", true, nil, "", "")
if err != nil {
panic(err)
}
diff --git a/pkg/commands/container.go b/pkg/commands/container.go
index 32660190..8e33ff60 100644
--- a/pkg/commands/container.go
+++ b/pkg/commands/container.go
@@ -57,6 +57,12 @@ func (c *Container) Remove(options container.RemoveOptions) error {
return nil
}
+// Start starts the container
+func (c *Container) Start() error {
+ c.Log.Warn(fmt.Sprintf("starting container %s", c.Name))
+ return c.Client.ContainerStart(context.Background(), c.ID, container.StartOptions{})
+}
+
// Stop stops the container
func (c *Container) Stop() error {
c.Log.Warn(fmt.Sprintf("stopping container %s", c.Name))
diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go
index 9d86d97d..0ce4fba1 100644
--- a/pkg/commands/docker.go
+++ b/pkg/commands/docker.go
@@ -9,6 +9,8 @@ import (
ogLog "log"
"os"
"os/exec"
+ "path/filepath"
+ "sort"
"strings"
"sync"
"time"
@@ -39,9 +41,11 @@ type DockerCommand struct {
Config *config.AppConfig
Client *client.Client
InDockerComposeProject bool
- ErrorChan chan error
- ContainerMutex deadlock.Mutex
- ServiceMutex deadlock.Mutex
+ // LocalProjectName is the compose project name for the directory where lazydocker was launched.
+ LocalProjectName string
+ ErrorChan chan error
+ ContainerMutex deadlock.Mutex
+ ServiceMutex deadlock.Mutex
Closers []io.Closer
}
@@ -61,12 +65,22 @@ type CommandObject struct {
Image *Image
Volume *Volume
Network *Network
+ Project *Project
}
// NewCommandObject takes a command object and returns a default command object with the passed command object merged in
func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject {
defaultObj := CommandObject{DockerCompose: c.Config.UserConfig.CommandTemplates.DockerCompose}
_ = mergo.Merge(&defaultObj, obj)
+
+ // When operating on a specific project, include -p flag so that
+ // 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 != "" {
+ defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, obj.Project.Name)
+ }
+
return defaultObj
}
@@ -193,7 +207,7 @@ func (c *DockerCommand) CreateClientStatMonitor(container *Container) {
container.MonitoringStats = false
}
-func (c *DockerCommand) RefreshContainersAndServices(currentServices []*Service, currentContainers []*Container) ([]*Container, []*Service, error) {
+func (c *DockerCommand) RefreshContainersAndServices(currentContainers []*Container) ([]*Container, []*Service, error) {
c.ServiceMutex.Lock()
defer c.ServiceMutex.Unlock()
@@ -202,27 +216,136 @@ func (c *DockerCommand) RefreshContainersAndServices(currentServices []*Service,
return nil, nil, err
}
- var services []*Service
- // we only need to get these services once because they won't change in the runtime of the program
- if currentServices != nil {
- services = currentServices
- } else {
- services, err = c.GetServices()
+ // Derive services from container labels (covers all projects)
+ services := c.GetServicesFromContainers(containers)
+
+ var composeServices []*Service
+ if c.InDockerComposeProject {
+ composeServices, err = c.GetServices()
if err != nil {
- return nil, nil, err
+ c.Log.Warn("Failed to get compose services: " + err.Error())
}
}
+ // Determine the local project name before merging services, since
+ // mergeServices needs it. We match compose service names against container
+ // labels to handle cases where the project name differs from the directory
+ // name (e.g. a `name:` directive in the compose file).
+ if c.LocalProjectName == "" && c.InDockerComposeProject && composeServices != nil {
+ for _, ctr := range containers {
+ if ctr.ProjectName == "" || ctr.ServiceName == "" {
+ continue
+ }
+ for _, svc := range composeServices {
+ if ctr.ServiceName == svc.Name {
+ c.LocalProjectName = ctr.ProjectName
+ break
+ }
+ }
+ if c.LocalProjectName != "" {
+ break
+ }
+ }
+ // Fall back to directory name
+ if c.LocalProjectName == "" && c.Config.ProjectDir != "" {
+ c.LocalProjectName = filepath.Base(c.Config.ProjectDir)
+ }
+ }
+
+ // Merge compose services (which include stopped services) with
+ // container-derived services from all projects
+ if composeServices != nil {
+ services = c.mergeServices(services, composeServices)
+ }
+
c.assignContainersToServices(containers, services)
return containers, services, nil
}
+// GetServicesFromContainers derives services from container labels for all projects
+func (c *DockerCommand) GetServicesFromContainers(containers []*Container) []*Service {
+ // Use project+service as key to avoid duplicates
+ type serviceKey struct {
+ project string
+ service string
+ }
+ seen := make(map[serviceKey]bool)
+ services := make([]*Service, 0, len(containers))
+
+ for _, ctr := range containers {
+ if ctr.ServiceName == "" || ctr.OneOff {
+ continue
+ }
+ key := serviceKey{project: ctr.ProjectName, service: ctr.ServiceName}
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ services = append(services, &Service{
+ Name: ctr.ServiceName,
+ ID: ctr.ProjectName + "-" + ctr.ServiceName,
+ ProjectName: ctr.ProjectName,
+ OSCommand: c.OSCommand,
+ Log: c.Log,
+ DockerCommand: c,
+ })
+ }
+
+ return services
+}
+
+// mergeServices merges compose services (which may lack ProjectName) with
+// container-derived services. Compose services take priority because they
+// include services without running containers.
+func (c *DockerCommand) mergeServices(containerServices []*Service, composeServices []*Service) []*Service {
+ // Set project name on compose services
+ for _, svc := range composeServices {
+ if svc.ProjectName == "" {
+ svc.ProjectName = c.LocalProjectName
+ }
+ }
+
+ // Build a set of compose service names for the local project
+ composeServiceNames := make(map[string]bool)
+ for _, svc := range composeServices {
+ composeServiceNames[svc.Name] = true
+ }
+
+ // Start with compose services, then add container-derived services
+ // that aren't already covered by compose (i.e. from other projects)
+ result := make([]*Service, 0, len(composeServices)+len(containerServices))
+ result = append(result, composeServices...)
+
+ for _, svc := range containerServices {
+ if svc.ProjectName == c.LocalProjectName && composeServiceNames[svc.Name] {
+ continue // already covered by compose service
+ }
+ result = append(result, svc)
+ }
+
+ return result
+}
+
+// GetProjectNames returns all unique project names from containers
+func (c *DockerCommand) GetProjectNames(containers []*Container) []string {
+ seen := make(map[string]bool)
+ var names []string
+ for _, ctr := range containers {
+ if ctr.ProjectName != "" && !seen[ctr.ProjectName] {
+ seen[ctr.ProjectName] = true
+ names = append(names, ctr.ProjectName)
+ }
+ }
+ sort.Strings(names)
+ return names
+}
+
func (c *DockerCommand) assignContainersToServices(containers []*Container, services []*Service) {
L:
for _, service := range services {
for _, ctr := range containers {
- if !ctr.OneOff && ctr.ServiceName == service.Name {
+ if !ctr.OneOff && ctr.ServiceName == service.Name && ctr.ProjectName == service.ProjectName {
service.Container = ctr
continue L
}
@@ -311,7 +434,8 @@ func (c *DockerCommand) GetServices() ([]*Service, error) {
for i, str := range lines {
services[i] = &Service{
Name: str,
- ID: str,
+ ID: c.LocalProjectName + "-" + str,
+ ProjectName: c.LocalProjectName,
OSCommand: c.OSCommand,
Log: c.Log,
DockerCommand: c,
@@ -351,11 +475,11 @@ func (c *DockerCommand) SetContainerDetails(containers []*Container) {
}
// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose
-func (c *DockerCommand) ViewAllLogs() (*exec.Cmd, error) {
+func (c *DockerCommand) ViewAllLogs(project *Project) (*exec.Cmd, error) {
cmd := c.OSCommand.ExecutableFromString(
utils.ApplyTemplate(
c.OSCommand.Config.UserConfig.CommandTemplates.ViewAllLogs,
- c.NewCommandObject(CommandObject{}),
+ c.NewCommandObject(CommandObject{Project: project}),
),
)
@@ -366,10 +490,15 @@ func (c *DockerCommand) ViewAllLogs() (*exec.Cmd, error) {
// DockerComposeConfig returns the result of 'docker-compose config'
func (c *DockerCommand) DockerComposeConfig() string {
+ return c.DockerComposeConfigForProject(nil)
+}
+
+// DockerComposeConfigForProject returns the result of 'docker-compose config' for a specific project
+func (c *DockerCommand) DockerComposeConfigForProject(project *Project) string {
output, err := c.OSCommand.RunCommandWithOutput(
utils.ApplyTemplate(
c.OSCommand.Config.UserConfig.CommandTemplates.DockerComposeConfig,
- c.NewCommandObject(CommandObject{}),
+ c.NewCommandObject(CommandObject{Project: project}),
),
)
if err != nil {
diff --git a/pkg/commands/service.go b/pkg/commands/service.go
index 755b7f5f..5006eefe 100644
--- a/pkg/commands/service.go
+++ b/pkg/commands/service.go
@@ -13,6 +13,7 @@ import (
type Service struct {
Name string
ID string
+ ProjectName string
OSCommand *OSCommand
Log *logrus.Entry
Container *Container
diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go
index 9c0ad474..0ebc1796 100644
--- a/pkg/config/app_config.go
+++ b/pkg/config/app_config.go
@@ -488,10 +488,11 @@ type AppConfig struct {
UserConfig *UserConfig
ConfigDir string
ProjectDir string
+ ProjectName string
}
// NewAppConfig makes a new app config
-func NewAppConfig(name, version, commit, date string, buildSource string, debuggingFlag bool, composeFiles []string, projectDir string) (*AppConfig, error) {
+func NewAppConfig(name, version, commit, date string, buildSource string, debuggingFlag bool, composeFiles []string, projectDir string, projectName string) (*AppConfig, error) {
configDir, err := findOrCreateConfigDir(name)
if err != nil {
return nil, err
@@ -517,6 +518,7 @@ func NewAppConfig(name, version, commit, date string, buildSource string, debugg
UserConfig: userConfig,
ConfigDir: configDir,
ProjectDir: projectDir,
+ ProjectName: projectName,
}
return appConfig, nil
diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go
index 5e7c2922..cd8167e4 100644
--- a/pkg/config/app_config_test.go
+++ b/pkg/config/app_config_test.go
@@ -9,7 +9,7 @@ import (
func TestDockerComposeCommandNoFiles(t *testing.T) {
composeFiles := []string{}
- conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, composeFiles, "projectDir")
+ conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, composeFiles, "projectDir", "")
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
@@ -23,7 +23,7 @@ func TestDockerComposeCommandNoFiles(t *testing.T) {
func TestDockerComposeCommandSingleFile(t *testing.T) {
composeFiles := []string{"one.yml"}
- conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, composeFiles, "projectDir")
+ conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, composeFiles, "projectDir", "")
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
@@ -37,7 +37,7 @@ func TestDockerComposeCommandSingleFile(t *testing.T) {
func TestDockerComposeCommandMultipleFiles(t *testing.T) {
composeFiles := []string{"one.yml", "two.yml", "three.yml"}
- conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, composeFiles, "projectDir")
+ conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, composeFiles, "projectDir", "")
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
@@ -52,7 +52,7 @@ func TestDockerComposeCommandMultipleFiles(t *testing.T) {
func TestWritingToConfigFile(t *testing.T) {
// init the AppConfig
emptyComposeFiles := []string{}
- conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, emptyComposeFiles, "projectDir")
+ conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, emptyComposeFiles, "projectDir", "")
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
diff --git a/pkg/gui/arrangement.go b/pkg/gui/arrangement.go
index bcd4aa1c..744f4467 100644
--- a/pkg/gui/arrangement.go
+++ b/pkg/gui/arrangement.go
@@ -175,11 +175,21 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box {
return defaultBox
}
- return append([]*boxlayout.Box{
- {
+ // The project panel is compact (Size: 3) when not focused, but expands
+ // when focused to show the list of projects.
+ projectBox := &boxlayout.Box{
+ Window: sideWindowNames[0],
+ Size: 3,
+ }
+ if currentWindow == sideWindowNames[0] {
+ projectBox = &boxlayout.Box{
Window: sideWindowNames[0],
- Size: 3,
- },
+ Weight: 2,
+ }
+ }
+
+ return append([]*boxlayout.Box{
+ projectBox,
}, lo.Map(sideWindowNames[1:], func(window string, _ int) *boxlayout.Box {
return accordionBox(&boxlayout.Box{Window: window, Weight: 1})
})...)
diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go
index 92aa95b3..4bde93ec 100644
--- a/pkg/gui/containers_panel.go
+++ b/pkg/gui/containers_panel.go
@@ -27,7 +27,7 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container]
}
return !lo.SomeBy(gui.Panels.Services.List.GetAllItems(), func(service *commands.Service) bool {
- return service.Name == container.ServiceName
+ return service.Name == container.ServiceName && service.ProjectName == container.ProjectName
})
}
@@ -94,6 +94,16 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container]
return false
}
+ // Filter by selected project. Containers with no project (truly
+ // standalone, not from any compose project) are always shown.
+ selectedProject := gui.getSelectedProjectName()
+ if selectedProject == "" {
+ selectedProject = gui.DockerCommand.LocalProjectName
+ }
+ if selectedProject != "" && container.ProjectName != "" && container.ProjectName != selectedProject {
+ return false
+ }
+
return true
},
GetTableCells: func(container *commands.Container) []string {
@@ -256,7 +266,6 @@ func (gui *Gui) refreshContainersAndServices() error {
selectedService, isServiceSelected := gui.Panels.Services.List.TryGet(originalSelectedLineIdx)
containers, services, err := gui.DockerCommand.RefreshContainersAndServices(
- gui.Panels.Services.List.GetAllItems(),
gui.Panels.Containers.List.GetAllItems(),
)
if err != nil {
@@ -283,10 +292,8 @@ func (gui *Gui) refreshContainersAndServices() error {
}
func (gui *Gui) renderContainersAndServices() error {
- if gui.DockerCommand.InDockerComposeProject {
- if err := gui.Panels.Services.RerenderList(); err != nil {
- return err
- }
+ if err := gui.Panels.Services.RerenderList(); err != nil {
+ return err
}
if err := gui.Panels.Containers.RerenderList(); err != nil {
diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go
index fa6199f3..e7b55463 100644
--- a/pkg/gui/gui.go
+++ b/pkg/gui/gui.go
@@ -297,12 +297,12 @@ func (gui *Gui) updateContainerDetails() error {
func (gui *Gui) refresh() {
go func() {
- if err := gui.refreshProject(); err != nil {
+ // Refresh containers/services first, then projects (which depend on
+ // container labels to discover projects).
+ if err := gui.refreshContainersAndServices(); err != nil {
gui.Log.Error(err)
}
- }()
- go func() {
- if err := gui.refreshContainersAndServices(); err != nil {
+ if err := gui.refreshProject(); err != nil {
gui.Log.Error(err)
}
}()
diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go
index ea28f507..3b202c82 100644
--- a/pkg/gui/layout.go
+++ b/pkg/gui/layout.go
@@ -120,9 +120,11 @@ func (gui *Gui) focusPointInView(view *gocui.View) {
return
}
- currentPanel, ok := gui.currentListPanel()
- if ok {
- currentPanel.Refocus()
+ for _, panel := range gui.allListPanels() {
+ if panel.GetView() == view {
+ panel.Refocus()
+ return
+ }
}
}
diff --git a/pkg/gui/panels/side_list_panel.go b/pkg/gui/panels/side_list_panel.go
index 2473e2ce..26ddc798 100644
--- a/pkg/gui/panels/side_list_panel.go
+++ b/pkg/gui/panels/side_list_panel.go
@@ -47,6 +47,9 @@ type SideListPanel[T comparable] struct {
// a callback to invoke when the item is clicked
OnClick func(T) error
+ // a callback to invoke when a new item is selected (e.g. keyboard navigation)
+ OnSelect func(T) error
+
// returns the cells that we render to the view in a table format. The cells will
// be rendered with padding.
GetTableCells func(T) []string
@@ -116,6 +119,12 @@ func (self *SideListPanel[T]) HandleSelect() error {
self.Refocus()
+ if self.OnSelect != nil {
+ if err := self.OnSelect(item); err != nil {
+ return err
+ }
+ }
+
return self.renderContext(item)
}
diff --git a/pkg/gui/project_panel.go b/pkg/gui/project_panel.go
index 32c5ad99..ca9acf28 100644
--- a/pkg/gui/project_panel.go
+++ b/pkg/gui/project_panel.go
@@ -3,7 +3,6 @@ package gui
import (
"bytes"
"context"
- "path"
"strings"
"github.com/fatih/color"
@@ -16,34 +15,21 @@ import (
"github.com/jesseduffield/yaml"
)
-// Although at the moment we'll only have one project, in future we could have
-// a list of projects in the project panel.
-
func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] {
return &panels.SideListPanel[*commands.Project]{
ContextState: &panels.ContextState[*commands.Project]{
GetMainTabs: func() []panels.MainTab[*commands.Project] {
- if gui.DockerCommand.InDockerComposeProject {
- return []panels.MainTab[*commands.Project]{
- {
- Key: "logs",
- Title: gui.Tr.LogsTitle,
- Render: gui.renderAllLogs,
- },
- {
- Key: "config",
- Title: gui.Tr.DockerComposeConfigTitle,
- Render: gui.renderDockerComposeConfig,
- },
- {
- Key: "credits",
- Title: gui.Tr.CreditsTitle,
- Render: gui.renderCredits,
- },
- }
- }
-
return []panels.MainTab[*commands.Project]{
+ {
+ Key: "logs",
+ Title: gui.Tr.LogsTitle,
+ Render: gui.renderAllLogs,
+ },
+ {
+ Key: "config",
+ Title: gui.Tr.DockerComposeConfigTitle,
+ Render: gui.renderDockerComposeConfig,
+ },
{
Key: "credits",
Title: gui.Tr.CreditsTitle,
@@ -64,31 +50,87 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] {
Gui: gui.intoInterface(),
Sort: func(a *commands.Project, b *commands.Project) bool {
- return false
+ return a.Name < b.Name
},
GetTableCells: presentation.GetProjectDisplayStrings,
- // It doesn't make sense to filter a list of only one item.
- DisableFilter: true,
+ OnSelect: func(project *commands.Project) error {
+ // When a different project is selected, re-filter services and
+ // containers to show only those belonging to the selected project.
+ return gui.renderContainersAndServices()
+ },
}
}
func (gui *Gui) refreshProject() error {
- gui.Panels.Projects.SetItems([]*commands.Project{{Name: gui.getProjectName()}})
- return gui.Panels.Projects.RerenderList()
-}
+ projects := gui.getDiscoveredProjects()
-func (gui *Gui) getProjectName() string {
- projectName := path.Base(gui.Config.ProjectDir)
- if gui.DockerCommand.InDockerComposeProject {
- for _, service := range gui.Panels.Services.List.GetAllItems() {
- container := service.Container
- if container != nil && container.DetailsLoaded() {
- return container.Details.Config.Labels["com.docker.compose.project"]
+ // Preserve the current selection across refreshes. On the first refresh,
+ // select the project specified via -p flag, or fall back to the local project.
+ selectedName := gui.getSelectedProjectName()
+ if selectedName == "" {
+ if gui.Config.ProjectName != "" {
+ selectedName = gui.Config.ProjectName
+ } else {
+ selectedName = gui.DockerCommand.LocalProjectName
+ }
+ }
+
+ gui.Panels.Projects.SetItems(projects)
+
+ if selectedName != "" {
+ for i, p := range gui.Panels.Projects.List.GetItems() {
+ if p.Name == selectedName {
+ gui.Panels.Projects.SetSelectedLineIdx(i)
+ gui.Panels.Projects.Refocus()
+ break
}
}
}
- return projectName
+ return gui.Panels.Projects.RerenderList()
+}
+
+// getDiscoveredProjects returns all docker compose projects by examining container labels.
+// The local project (from docker-compose.yml in the current directory) is included if
+// it has running containers or if InDockerComposeProject is true.
+func (gui *Gui) getDiscoveredProjects() []*commands.Project {
+ containers := gui.Panels.Containers.List.GetAllItems()
+ projectNames := gui.DockerCommand.GetProjectNames(containers)
+
+ // If we're in a docker compose project but it has no running containers,
+ // still include it. We don't fall back to the directory name here to avoid
+ // briefly flashing the wrong project name on startup.
+ localName := gui.DockerCommand.LocalProjectName
+
+ if gui.DockerCommand.InDockerComposeProject && localName != "" {
+ found := false
+ for _, name := range projectNames {
+ if name == localName {
+ found = true
+ break
+ }
+ }
+ if !found {
+ projectNames = append([]string{localName}, projectNames...)
+ }
+ }
+
+ projects := make([]*commands.Project, len(projectNames))
+ for i, name := range projectNames {
+ projects[i] = &commands.Project{Name: name}
+ }
+
+ return projects
+}
+
+// getSelectedProjectName returns the name of the currently selected project,
+// or empty string if none is selected.
+func (gui *Gui) getSelectedProjectName() string {
+ project, err := gui.Panels.Projects.GetSelectedItem()
+ if err != nil {
+ return ""
+ }
+ return project.Name
}
func (gui *Gui) renderCredits(_project *commands.Project) tasks.TaskFunc {
@@ -112,7 +154,7 @@ func (gui *Gui) creditsStr() string {
}, "\n\n")
}
-func (gui *Gui) renderAllLogs(_project *commands.Project) tasks.TaskFunc {
+func (gui *Gui) renderAllLogs(project *commands.Project) tasks.TaskFunc {
return gui.NewTask(TaskOpts{
Autoscroll: true,
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
@@ -122,7 +164,7 @@ func (gui *Gui) renderAllLogs(_project *commands.Project) tasks.TaskFunc {
cmd := gui.OSCommand.RunCustomCommand(
utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.AllLogs,
- gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
+ gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
),
)
@@ -144,9 +186,14 @@ func (gui *Gui) renderAllLogs(_project *commands.Project) tasks.TaskFunc {
})
}
-func (gui *Gui) renderDockerComposeConfig(_project *commands.Project) tasks.TaskFunc {
+func (gui *Gui) renderDockerComposeConfig(project *commands.Project) tasks.TaskFunc {
+ if project != nil && project.Name != gui.DockerCommand.LocalProjectName {
+ return gui.NewSimpleRenderStringTask(func() string {
+ return "Compose config is not available for non-local projects"
+ })
+ }
return gui.NewSimpleRenderStringTask(func() string {
- return utils.ColoredYamlString(gui.DockerCommand.DockerComposeConfig())
+ return utils.ColoredYamlString(gui.DockerCommand.DockerComposeConfigForProject(project))
})
}
@@ -173,7 +220,8 @@ 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 {
- c, err := gui.DockerCommand.ViewAllLogs()
+ project, _ := gui.Panels.Projects.GetSelectedItem()
+ c, err := gui.DockerCommand.ViewAllLogs(project)
if err != nil {
return gui.createErrorPanel(err.Error())
}
diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go
index c9dd4d1d..fb768a74 100644
--- a/pkg/gui/services_panel.go
+++ b/pkg/gui/services_panel.go
@@ -74,11 +74,24 @@ func (gui *Gui) getServicesPanel() *panels.SideListPanel[*commands.Service] {
return a.Name < b.Name
},
+ Filter: func(service *commands.Service) bool {
+ selectedProject := gui.getSelectedProjectName()
+ if selectedProject == "" {
+ // Before any project is selected (e.g. startup), default to
+ // the local project so we don't briefly flash all services.
+ selectedProject = gui.DockerCommand.LocalProjectName
+ }
+ if selectedProject == "" {
+ return true
+ }
+ return service.ProjectName == selectedProject
+ },
GetTableCells: func(service *commands.Service) []string {
return presentation.GetServiceDisplayStrings(&gui.Config.UserConfig.Gui, service)
},
Hide: func() bool {
- return !gui.DockerCommand.InDockerComposeProject
+ // Show services panel if there are any compose projects (local or discovered)
+ return !gui.DockerCommand.InDockerComposeProject && len(gui.Panels.Services.List.GetAllItems()) == 0
},
}
}
@@ -142,13 +155,24 @@ func (r *commandOption) getDisplayStrings() []string {
return []string{r.description, color.New(color.FgCyan).Sprint(r.command)}
}
+// isServiceFromLocalProject returns true if the given service belongs to the
+// local compose project (the one whose compose file is in the current directory).
+// Compose commands like up/stop/restart only work for local project services.
+func (gui *Gui) isServiceFromLocalProject(service *commands.Service) bool {
+ return service.ProjectName == gui.DockerCommand.LocalProjectName
+}
+
func (gui *Gui) handleServiceRemoveMenu(g *gocui.Gui, v *gocui.View) error {
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
- composeCommand := gui.Config.UserConfig.CommandTemplates.DockerCompose
+ if !gui.isServiceFromLocalProject(service) {
+ return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
+ }
+
+ composeCommand := gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}).DockerCompose
options := []*commandOption{
{
@@ -202,10 +226,15 @@ func (gui *Gui) handleServiceStop(g *gocui.Gui, v *gocui.View) error {
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.StopService, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error {
+ if !gui.isServiceFromLocalProject(service) {
+ if service.Container == nil {
+ return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
+ }
+ return service.Container.Stop()
+ }
if err := service.Stop(); err != nil {
return gui.createErrorPanel(err.Error())
}
-
return nil
})
}, nil)
@@ -217,6 +246,10 @@ func (gui *Gui) handleServiceUp(g *gocui.Gui, v *gocui.View) error {
return nil
}
+ if !gui.isServiceFromLocalProject(service) {
+ return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
+ }
+
return gui.WithWaitingStatus(gui.Tr.UppingServiceStatus, func() error {
if err := service.Up(); err != nil {
return gui.createErrorPanel(err.Error())
@@ -233,10 +266,15 @@ func (gui *Gui) handleServiceRestart(g *gocui.Gui, v *gocui.View) error {
}
return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error {
+ if !gui.isServiceFromLocalProject(service) {
+ if service.Container == nil {
+ return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
+ }
+ return service.Container.Restart()
+ }
if err := service.Restart(); err != nil {
return gui.createErrorPanel(err.Error())
}
-
return nil
})
}
@@ -247,11 +285,19 @@ func (gui *Gui) handleServiceStart(g *gocui.Gui, v *gocui.View) error {
return nil
}
+ if !gui.isServiceFromLocalProject(service) {
+ if service.Container == nil {
+ return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
+ }
+ return gui.WithWaitingStatus(gui.Tr.StartingStatus, func() error {
+ return service.Container.Start()
+ })
+ }
+
return gui.WithWaitingStatus(gui.Tr.StartingStatus, func() error {
if err := service.Start(); err != nil {
return gui.createErrorPanel(err.Error())
}
-
return nil
})
}
@@ -289,10 +335,14 @@ 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 {
+ return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
+ }
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmUpProject, func(g *gocui.Gui, v *gocui.View) error {
cmdStr := utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.Up,
- gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
+ gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
)
return gui.WithWaitingStatus(gui.Tr.UppingProjectStatus, func() error {
@@ -305,14 +355,18 @@ 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 {
+ return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
+ }
downCommand := utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.Down,
- gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
+ gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
)
downWithVolumesCommand := utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.DownWithVolumes,
- gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
+ gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
)
options := []*commandOption{
@@ -361,6 +415,10 @@ func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error {
return nil
}
+ if !gui.isServiceFromLocalProject(service) {
+ return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
+ }
+
rebuildCommand := utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.RebuildService,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}),
@@ -465,8 +523,9 @@ 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{})
+ commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project})
return gui.createBulkCommandMenu(bulkCommands, commandObject)
}
diff --git a/pkg/gui/views.go b/pkg/gui/views.go
index 3de6d61a..95553f5a 100644
--- a/pkg/gui/views.go
+++ b/pkg/gui/views.go
@@ -120,6 +120,8 @@ func (gui *Gui) createAllViews() error {
gui.Views.Project.Title = gui.Tr.ProjectTitle
gui.Views.Project.TitlePrefix = "[1]"
+ gui.Views.Project.Highlight = true
+ gui.Views.Project.SelBgColor = selectedLineBgColor
gui.Views.Services.Highlight = true
gui.Views.Services.Title = gui.Tr.ServicesTitle
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index d1ed0320..d86b0c84 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -110,17 +110,18 @@ type TranslationSet struct {
OpenInBrowser string
SortContainersByState string
- LogsTitle string
- ConfigTitle string
- EnvTitle string
- DockerComposeConfigTitle string
- StatsTitle string
- CreditsTitle string
- ContainerConfigTitle string
- ContainerEnvTitle string
- NothingToDisplay string
- NoContainerForService string
- CannotDisplayEnvVariables string
+ LogsTitle string
+ ConfigTitle string
+ EnvTitle string
+ DockerComposeConfigTitle string
+ StatsTitle string
+ CreditsTitle string
+ ContainerConfigTitle string
+ ContainerEnvTitle string
+ NothingToDisplay string
+ NoContainerForService string
+ CannotDisplayEnvVariables string
+ CannotManageNonLocalService string
No string
Yes string
@@ -217,30 +218,31 @@ func englishSet() TranslationSet {
OpenInBrowser: "open in browser (first port is http)",
SortContainersByState: "sort containers by state",
- GlobalTitle: "Global",
- MainTitle: "Main",
- ProjectTitle: "Project",
- ServicesTitle: "Services",
- ContainersTitle: "Containers",
- StandaloneContainersTitle: "Standalone Containers",
- ImagesTitle: "Images",
- VolumesTitle: "Volumes",
- NetworksTitle: "Networks",
- CustomCommandTitle: "Custom Command:",
- BulkCommandTitle: "Bulk Command:",
- ErrorTitle: "Error",
- LogsTitle: "Logs",
- ConfigTitle: "Config",
- EnvTitle: "Env",
- DockerComposeConfigTitle: "Docker-Compose Config",
- TopTitle: "Top",
- StatsTitle: "Stats",
- CreditsTitle: "About",
- ContainerConfigTitle: "Container Config",
- ContainerEnvTitle: "Container Env",
- NothingToDisplay: "Nothing to display",
- NoContainerForService: "No logs to show; service is not associated with a container",
- CannotDisplayEnvVariables: "Something went wrong while displaying environment variables",
+ GlobalTitle: "Global",
+ MainTitle: "Main",
+ ProjectTitle: "Project",
+ ServicesTitle: "Services",
+ ContainersTitle: "Containers",
+ StandaloneContainersTitle: "Standalone Containers",
+ ImagesTitle: "Images",
+ VolumesTitle: "Volumes",
+ NetworksTitle: "Networks",
+ CustomCommandTitle: "Custom Command:",
+ BulkCommandTitle: "Bulk Command:",
+ ErrorTitle: "Error",
+ LogsTitle: "Logs",
+ ConfigTitle: "Config",
+ EnvTitle: "Env",
+ DockerComposeConfigTitle: "Docker-Compose Config",
+ TopTitle: "Top",
+ StatsTitle: "Stats",
+ CreditsTitle: "About",
+ ContainerConfigTitle: "Container Config",
+ ContainerEnvTitle: "Container Env",
+ NothingToDisplay: "Nothing to display",
+ NoContainerForService: "No logs to show; service is not associated with a container",
+ CannotDisplayEnvVariables: "Something went wrong while displaying environment variables",
+ CannotManageNonLocalService: "This service belongs to a different compose project. Run lazydocker from that project's directory to manage it.",
NoContainers: "No containers",
NoContainer: "No container",