From 26ce871368069495b9c504b8c0f8bc720879d1b2 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 15 Mar 2026 14:31:38 +1100 Subject: [PATCH] Improve projects support --- config/config.yml | 0 docker-compose.yml | 1 - docs/keybindings/Keybindings_de.md | 1 + docs/keybindings/Keybindings_en.md | 1 + docs/keybindings/Keybindings_es.md | 1 + docs/keybindings/Keybindings_fr.md | 1 + docs/keybindings/Keybindings_nl.md | 1 + docs/keybindings/Keybindings_pl.md | 1 + docs/keybindings/Keybindings_pt.md | 1 + docs/keybindings/Keybindings_tr.md | 1 + docs/keybindings/Keybindings_zh.md | 1 + main.go | 4 +- pkg/cheatsheet/generate.go | 2 +- pkg/commands/container.go | 6 ++ pkg/commands/docker.go | 161 ++++++++++++++++++++++++++--- pkg/commands/service.go | 1 + pkg/config/app_config.go | 4 +- pkg/config/app_config_test.go | 8 +- pkg/gui/arrangement.go | 18 +++- pkg/gui/containers_panel.go | 19 ++-- pkg/gui/gui.go | 8 +- pkg/gui/layout.go | 8 +- pkg/gui/panels/side_list_panel.go | 9 ++ pkg/gui/project_panel.go | 134 ++++++++++++++++-------- pkg/gui/services_panel.go | 77 ++++++++++++-- pkg/gui/views.go | 2 + pkg/i18n/english.go | 72 ++++++------- 27 files changed, 415 insertions(+), 128 deletions(-) create mode 100644 config/config.yml 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",