Merge pull request #774 from jesseduffield/better-project-support

Improve projects support
This commit is contained in:
Jesse Duffield 2026-03-15 14:55:35 +11:00 committed by GitHub
commit 2ebe3848d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 430 additions and 128 deletions

15
.claude/settings.json Normal file
View file

@ -0,0 +1,15 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | xargs -I{} gofumpt -w {}"
}
]
}
]
}
}

0
config/config.yml Normal file
View file

View file

@ -1,4 +1,3 @@
version: '3'
services: services:
lazydocker: lazydocker:
build: build:

View file

@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>enter</kbd>: fokussieren aufs Hauptpanel <kbd>enter</kbd>: fokussieren aufs Hauptpanel
<kbd>[</kbd>: vorheriges Tab <kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab <kbd>]</kbd>: nächstes Tab
<kbd>/</kbd>: filter list
</pre> </pre>
## Container ## Container

View file

@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>enter</kbd>: focus main panel <kbd>enter</kbd>: focus main panel
<kbd>[</kbd>: previous tab <kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab <kbd>]</kbd>: next tab
<kbd>/</kbd>: filter list
</pre> </pre>
## Containers ## Containers

View file

@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>enter</kbd>: enfocar panel principal <kbd>enter</kbd>: enfocar panel principal
<kbd>[</kbd>: anterior pestaña <kbd>[</kbd>: anterior pestaña
<kbd>]</kbd>: siguiente pestaña <kbd>]</kbd>: siguiente pestaña
<kbd>/</kbd>: filtrar lista
</pre> </pre>
## Contenedores ## Contenedores

View file

@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>enter</kbd>: focus panneau principal <kbd>enter</kbd>: focus panneau principal
<kbd>[</kbd>: onglet précédent <kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant <kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre> </pre>
## Conteneurs ## Conteneurs

View file

@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>enter</kbd>: focus hoofdpaneel <kbd>enter</kbd>: focus hoofdpaneel
<kbd>[</kbd>: vorige tab <kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab <kbd>]</kbd>: volgende tab
<kbd>/</kbd>: filter list
</pre> </pre>
## Containers ## Containers

View file

@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>enter</kbd>: skup na głównym panelu <kbd>enter</kbd>: skup na głównym panelu
<kbd>[</kbd>: poprzednia zakładka <kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka <kbd>]</kbd>: następna zakładka
<kbd>/</kbd>: filter list
</pre> </pre>
## Kontenery ## Kontenery

View file

@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>enter</kbd>: focar no painel principal <kbd>enter</kbd>: focar no painel principal
<kbd>[</kbd>: aba anterior <kbd>[</kbd>: aba anterior
<kbd>]</kbd>: próxima aba <kbd>]</kbd>: próxima aba
<kbd>/</kbd>: filtrar lista
</pre> </pre>
## Contêineres ## Contêineres

View file

@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>enter</kbd>: ana panele odaklan <kbd>enter</kbd>: ana panele odaklan
<kbd>[</kbd>: önceki sekme <kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme <kbd>]</kbd>: sonraki sekme
<kbd>/</kbd>: filter list
</pre> </pre>
## Konteynerler ## Konteynerler

View file

@ -11,6 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>enter</kbd>: 聚焦主面板 <kbd>enter</kbd>: 聚焦主面板
<kbd>[</kbd>: 上一个选项卡 <kbd>[</kbd>: 上一个选项卡
<kbd>]</kbd>: 下一个选项卡 <kbd>]</kbd>: 下一个选项卡
<kbd>/</kbd>: 过滤列表
</pre> </pre>
## 容器 ## 容器

View file

@ -29,6 +29,7 @@ var (
configFlag = false configFlag = false
debuggingFlag = false debuggingFlag = false
composeFiles []string composeFiles []string
projectName string
) )
func main() { func main() {
@ -51,6 +52,7 @@ func main() {
flaggy.Bool(&configFlag, "c", "config", "Print the current default config") flaggy.Bool(&configFlag, "c", "config", "Print the current default config")
flaggy.Bool(&debuggingFlag, "d", "debug", "a boolean") flaggy.Bool(&debuggingFlag, "d", "debug", "a boolean")
flaggy.StringSlice(&composeFiles, "f", "file", "Specify alternate compose files") flaggy.StringSlice(&composeFiles, "f", "file", "Specify alternate compose files")
flaggy.String(&projectName, "p", "project", "Specify a docker compose project name")
flaggy.SetVersion(info) flaggy.SetVersion(info)
flaggy.Parse() flaggy.Parse()
@ -71,7 +73,7 @@ func main() {
log.Fatal(err.Error()) 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 { if err != nil {
log.Fatal(err.Error()) log.Fatal(err.Error())
} }

View file

@ -33,7 +33,7 @@ func Generate() {
} }
func generateAtDir(dir string) { func generateAtDir(dir string) {
mConfig, err := config.NewAppConfig("lazydocker", "", "", "", "", true, nil, "") mConfig, err := config.NewAppConfig("lazydocker", "", "", "", "", true, nil, "", "")
if err != nil { if err != nil {
panic(err) panic(err)
} }

View file

@ -57,6 +57,12 @@ func (c *Container) Remove(options container.RemoveOptions) error {
return nil 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 // Stop stops the container
func (c *Container) Stop() error { func (c *Container) Stop() error {
c.Log.Warn(fmt.Sprintf("stopping container %s", c.Name)) c.Log.Warn(fmt.Sprintf("stopping container %s", c.Name))

View file

@ -9,6 +9,8 @@ import (
ogLog "log" ogLog "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -39,6 +41,8 @@ type DockerCommand struct {
Config *config.AppConfig Config *config.AppConfig
Client *client.Client Client *client.Client
InDockerComposeProject bool InDockerComposeProject bool
// LocalProjectName is the compose project name for the directory where lazydocker was launched.
LocalProjectName string
ErrorChan chan error ErrorChan chan error
ContainerMutex deadlock.Mutex ContainerMutex deadlock.Mutex
ServiceMutex deadlock.Mutex ServiceMutex deadlock.Mutex
@ -61,12 +65,22 @@ type CommandObject struct {
Image *Image Image *Image
Volume *Volume Volume *Volume
Network *Network Network *Network
Project *Project
} }
// NewCommandObject takes a command object and returns a default command object with the passed command object merged in // 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 { func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject {
defaultObj := CommandObject{DockerCompose: c.Config.UserConfig.CommandTemplates.DockerCompose} defaultObj := CommandObject{DockerCompose: c.Config.UserConfig.CommandTemplates.DockerCompose}
_ = mergo.Merge(&defaultObj, obj) _ = 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 return defaultObj
} }
@ -193,7 +207,7 @@ func (c *DockerCommand) CreateClientStatMonitor(container *Container) {
container.MonitoringStats = false 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() c.ServiceMutex.Lock()
defer c.ServiceMutex.Unlock() defer c.ServiceMutex.Unlock()
@ -202,27 +216,136 @@ func (c *DockerCommand) RefreshContainersAndServices(currentServices []*Service,
return nil, nil, err return nil, nil, err
} }
var services []*Service // Derive services from container labels (covers all projects)
// we only need to get these services once because they won't change in the runtime of the program services := c.GetServicesFromContainers(containers)
if currentServices != nil {
services = currentServices var composeServices []*Service
} else { if c.InDockerComposeProject {
services, err = c.GetServices() composeServices, err = c.GetServices()
if err != nil { 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) c.assignContainersToServices(containers, services)
return containers, services, nil 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) { func (c *DockerCommand) assignContainersToServices(containers []*Container, services []*Service) {
L: L:
for _, service := range services { for _, service := range services {
for _, ctr := range containers { 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 service.Container = ctr
continue L continue L
} }
@ -311,7 +434,8 @@ func (c *DockerCommand) GetServices() ([]*Service, error) {
for i, str := range lines { for i, str := range lines {
services[i] = &Service{ services[i] = &Service{
Name: str, Name: str,
ID: str, ID: c.LocalProjectName + "-" + str,
ProjectName: c.LocalProjectName,
OSCommand: c.OSCommand, OSCommand: c.OSCommand,
Log: c.Log, Log: c.Log,
DockerCommand: c, DockerCommand: c,
@ -351,11 +475,11 @@ func (c *DockerCommand) SetContainerDetails(containers []*Container) {
} }
// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose // 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( cmd := c.OSCommand.ExecutableFromString(
utils.ApplyTemplate( utils.ApplyTemplate(
c.OSCommand.Config.UserConfig.CommandTemplates.ViewAllLogs, 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' // DockerComposeConfig returns the result of 'docker-compose config'
func (c *DockerCommand) DockerComposeConfig() string { 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( output, err := c.OSCommand.RunCommandWithOutput(
utils.ApplyTemplate( utils.ApplyTemplate(
c.OSCommand.Config.UserConfig.CommandTemplates.DockerComposeConfig, c.OSCommand.Config.UserConfig.CommandTemplates.DockerComposeConfig,
c.NewCommandObject(CommandObject{}), c.NewCommandObject(CommandObject{Project: project}),
), ),
) )
if err != nil { if err != nil {

View file

@ -13,6 +13,7 @@ import (
type Service struct { type Service struct {
Name string Name string
ID string ID string
ProjectName string
OSCommand *OSCommand OSCommand *OSCommand
Log *logrus.Entry Log *logrus.Entry
Container *Container Container *Container

View file

@ -488,10 +488,11 @@ type AppConfig struct {
UserConfig *UserConfig UserConfig *UserConfig
ConfigDir string ConfigDir string
ProjectDir string ProjectDir string
ProjectName string
} }
// NewAppConfig makes a new app config // 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) configDir, err := findOrCreateConfigDir(name)
if err != nil { if err != nil {
return nil, err return nil, err
@ -517,6 +518,7 @@ func NewAppConfig(name, version, commit, date string, buildSource string, debugg
UserConfig: userConfig, UserConfig: userConfig,
ConfigDir: configDir, ConfigDir: configDir,
ProjectDir: projectDir, ProjectDir: projectDir,
ProjectName: projectName,
} }
return appConfig, nil return appConfig, nil

View file

@ -9,7 +9,7 @@ import (
func TestDockerComposeCommandNoFiles(t *testing.T) { func TestDockerComposeCommandNoFiles(t *testing.T) {
composeFiles := []string{} 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 { if err != nil {
t.Fatalf("Unexpected error: %s", err) t.Fatalf("Unexpected error: %s", err)
} }
@ -23,7 +23,7 @@ func TestDockerComposeCommandNoFiles(t *testing.T) {
func TestDockerComposeCommandSingleFile(t *testing.T) { func TestDockerComposeCommandSingleFile(t *testing.T) {
composeFiles := []string{"one.yml"} 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 { if err != nil {
t.Fatalf("Unexpected error: %s", err) t.Fatalf("Unexpected error: %s", err)
} }
@ -37,7 +37,7 @@ func TestDockerComposeCommandSingleFile(t *testing.T) {
func TestDockerComposeCommandMultipleFiles(t *testing.T) { func TestDockerComposeCommandMultipleFiles(t *testing.T) {
composeFiles := []string{"one.yml", "two.yml", "three.yml"} 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 { if err != nil {
t.Fatalf("Unexpected error: %s", err) t.Fatalf("Unexpected error: %s", err)
} }
@ -52,7 +52,7 @@ func TestDockerComposeCommandMultipleFiles(t *testing.T) {
func TestWritingToConfigFile(t *testing.T) { func TestWritingToConfigFile(t *testing.T) {
// init the AppConfig // init the AppConfig
emptyComposeFiles := []string{} 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 { if err != nil {
t.Fatalf("Unexpected error: %s", err) t.Fatalf("Unexpected error: %s", err)
} }

View file

@ -175,11 +175,21 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box {
return defaultBox 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], Window: sideWindowNames[0],
Size: 3, Size: 3,
}, }
if currentWindow == sideWindowNames[0] {
projectBox = &boxlayout.Box{
Window: sideWindowNames[0],
Weight: 2,
}
}
return append([]*boxlayout.Box{
projectBox,
}, lo.Map(sideWindowNames[1:], func(window string, _ int) *boxlayout.Box { }, lo.Map(sideWindowNames[1:], func(window string, _ int) *boxlayout.Box {
return accordionBox(&boxlayout.Box{Window: window, Weight: 1}) return accordionBox(&boxlayout.Box{Window: window, Weight: 1})
})...) })...)

View file

@ -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 !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 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 return true
}, },
GetTableCells: func(container *commands.Container) []string { GetTableCells: func(container *commands.Container) []string {
@ -256,7 +266,6 @@ func (gui *Gui) refreshContainersAndServices() error {
selectedService, isServiceSelected := gui.Panels.Services.List.TryGet(originalSelectedLineIdx) selectedService, isServiceSelected := gui.Panels.Services.List.TryGet(originalSelectedLineIdx)
containers, services, err := gui.DockerCommand.RefreshContainersAndServices( containers, services, err := gui.DockerCommand.RefreshContainersAndServices(
gui.Panels.Services.List.GetAllItems(),
gui.Panels.Containers.List.GetAllItems(), gui.Panels.Containers.List.GetAllItems(),
) )
if err != nil { if err != nil {
@ -283,11 +292,9 @@ func (gui *Gui) refreshContainersAndServices() error {
} }
func (gui *Gui) renderContainersAndServices() error { func (gui *Gui) renderContainersAndServices() error {
if gui.DockerCommand.InDockerComposeProject {
if err := gui.Panels.Services.RerenderList(); err != nil { if err := gui.Panels.Services.RerenderList(); err != nil {
return err return err
} }
}
if err := gui.Panels.Containers.RerenderList(); err != nil { if err := gui.Panels.Containers.RerenderList(); err != nil {
return err return err

View file

@ -297,12 +297,12 @@ func (gui *Gui) updateContainerDetails() error {
func (gui *Gui) refresh() { func (gui *Gui) refresh() {
go func() { 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) gui.Log.Error(err)
} }
}() if err := gui.refreshProject(); err != nil {
go func() {
if err := gui.refreshContainersAndServices(); err != nil {
gui.Log.Error(err) gui.Log.Error(err)
} }
}() }()

View file

@ -120,9 +120,11 @@ func (gui *Gui) focusPointInView(view *gocui.View) {
return return
} }
currentPanel, ok := gui.currentListPanel() for _, panel := range gui.allListPanels() {
if ok { if panel.GetView() == view {
currentPanel.Refocus() panel.Refocus()
return
}
} }
} }

View file

@ -47,6 +47,9 @@ type SideListPanel[T comparable] struct {
// a callback to invoke when the item is clicked // a callback to invoke when the item is clicked
OnClick func(T) error 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 // returns the cells that we render to the view in a table format. The cells will
// be rendered with padding. // be rendered with padding.
GetTableCells func(T) []string GetTableCells func(T) []string
@ -116,6 +119,12 @@ func (self *SideListPanel[T]) HandleSelect() error {
self.Refocus() self.Refocus()
if self.OnSelect != nil {
if err := self.OnSelect(item); err != nil {
return err
}
}
return self.renderContext(item) return self.renderContext(item)
} }

View file

@ -3,7 +3,6 @@ package gui
import ( import (
"bytes" "bytes"
"context" "context"
"path"
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
@ -16,14 +15,10 @@ import (
"github.com/jesseduffield/yaml" "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] { func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] {
return &panels.SideListPanel[*commands.Project]{ return &panels.SideListPanel[*commands.Project]{
ContextState: &panels.ContextState[*commands.Project]{ ContextState: &panels.ContextState[*commands.Project]{
GetMainTabs: func() []panels.MainTab[*commands.Project] { GetMainTabs: func() []panels.MainTab[*commands.Project] {
if gui.DockerCommand.InDockerComposeProject {
return []panels.MainTab[*commands.Project]{ return []panels.MainTab[*commands.Project]{
{ {
Key: "logs", Key: "logs",
@ -41,15 +36,6 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] {
Render: gui.renderCredits, Render: gui.renderCredits,
}, },
} }
}
return []panels.MainTab[*commands.Project]{
{
Key: "credits",
Title: gui.Tr.CreditsTitle,
Render: gui.renderCredits,
},
}
}, },
GetItemContextCacheKey: func(project *commands.Project) string { GetItemContextCacheKey: func(project *commands.Project) string {
return "projects-" + project.Name return "projects-" + project.Name
@ -64,31 +50,87 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] {
Gui: gui.intoInterface(), Gui: gui.intoInterface(),
Sort: func(a *commands.Project, b *commands.Project) bool { Sort: func(a *commands.Project, b *commands.Project) bool {
return false return a.Name < b.Name
}, },
GetTableCells: presentation.GetProjectDisplayStrings, GetTableCells: presentation.GetProjectDisplayStrings,
// It doesn't make sense to filter a list of only one item. OnSelect: func(project *commands.Project) error {
DisableFilter: true, // 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 { func (gui *Gui) refreshProject() error {
gui.Panels.Projects.SetItems([]*commands.Project{{Name: gui.getProjectName()}}) projects := gui.getDiscoveredProjects()
// 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 gui.Panels.Projects.RerenderList() return gui.Panels.Projects.RerenderList()
} }
func (gui *Gui) getProjectName() string { // getDiscoveredProjects returns all docker compose projects by examining container labels.
projectName := path.Base(gui.Config.ProjectDir) // The local project (from docker-compose.yml in the current directory) is included if
if gui.DockerCommand.InDockerComposeProject { // it has running containers or if InDockerComposeProject is true.
for _, service := range gui.Panels.Services.List.GetAllItems() { func (gui *Gui) getDiscoveredProjects() []*commands.Project {
container := service.Container containers := gui.Panels.Containers.List.GetAllItems()
if container != nil && container.DetailsLoaded() { projectNames := gui.DockerCommand.GetProjectNames(containers)
return container.Details.Config.Labels["com.docker.compose.project"]
// 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...)
}
} }
return projectName 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 { func (gui *Gui) renderCredits(_project *commands.Project) tasks.TaskFunc {
@ -112,7 +154,7 @@ func (gui *Gui) creditsStr() string {
}, "\n\n") }, "\n\n")
} }
func (gui *Gui) renderAllLogs(_project *commands.Project) tasks.TaskFunc { func (gui *Gui) renderAllLogs(project *commands.Project) tasks.TaskFunc {
return gui.NewTask(TaskOpts{ return gui.NewTask(TaskOpts{
Autoscroll: true, Autoscroll: true,
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel, Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
@ -122,7 +164,7 @@ func (gui *Gui) renderAllLogs(_project *commands.Project) tasks.TaskFunc {
cmd := gui.OSCommand.RunCustomCommand( cmd := gui.OSCommand.RunCustomCommand(
utils.ApplyTemplate( utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.AllLogs, 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 gui.NewSimpleRenderStringTask(func() string {
return utils.ColoredYamlString(gui.DockerCommand.DockerComposeConfig()) return "Compose config is not available for non-local projects"
})
}
return gui.NewSimpleRenderStringTask(func() string {
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 // handleViewAllLogs switches to a subprocess viewing all the logs from docker-compose
func (gui *Gui) handleViewAllLogs(g *gocui.Gui, v *gocui.View) error { 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 { if err != nil {
return gui.createErrorPanel(err.Error()) return gui.createErrorPanel(err.Error())
} }

View file

@ -74,11 +74,24 @@ func (gui *Gui) getServicesPanel() *panels.SideListPanel[*commands.Service] {
return a.Name < b.Name 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 { GetTableCells: func(service *commands.Service) []string {
return presentation.GetServiceDisplayStrings(&gui.Config.UserConfig.Gui, service) return presentation.GetServiceDisplayStrings(&gui.Config.UserConfig.Gui, service)
}, },
Hide: func() bool { 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)} 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 { func (gui *Gui) handleServiceRemoveMenu(g *gocui.Gui, v *gocui.View) error {
service, err := gui.Panels.Services.GetSelectedItem() service, err := gui.Panels.Services.GetSelectedItem()
if err != nil { if err != nil {
return 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{ 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.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.StopService, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() 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 { if err := service.Stop(); err != nil {
return gui.createErrorPanel(err.Error()) return gui.createErrorPanel(err.Error())
} }
return nil return nil
}) })
}, nil) }, nil)
@ -217,6 +246,10 @@ func (gui *Gui) handleServiceUp(g *gocui.Gui, v *gocui.View) error {
return nil return nil
} }
if !gui.isServiceFromLocalProject(service) {
return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
}
return gui.WithWaitingStatus(gui.Tr.UppingServiceStatus, func() error { return gui.WithWaitingStatus(gui.Tr.UppingServiceStatus, func() error {
if err := service.Up(); err != nil { if err := service.Up(); err != nil {
return gui.createErrorPanel(err.Error()) 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 { 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 { if err := service.Restart(); err != nil {
return gui.createErrorPanel(err.Error()) return gui.createErrorPanel(err.Error())
} }
return nil return nil
}) })
} }
@ -247,11 +285,19 @@ func (gui *Gui) handleServiceStart(g *gocui.Gui, v *gocui.View) error {
return nil 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 { return gui.WithWaitingStatus(gui.Tr.StartingStatus, func() error {
if err := service.Start(); err != nil { if err := service.Start(); err != nil {
return gui.createErrorPanel(err.Error()) return gui.createErrorPanel(err.Error())
} }
return nil 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 { 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 { return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmUpProject, func(g *gocui.Gui, v *gocui.View) error {
cmdStr := utils.ApplyTemplate( cmdStr := utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.Up, 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 { 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 { 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( downCommand := utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.Down, gui.Config.UserConfig.CommandTemplates.Down,
gui.DockerCommand.NewCommandObject(commands.CommandObject{}), gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
) )
downWithVolumesCommand := utils.ApplyTemplate( downWithVolumesCommand := utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.DownWithVolumes, gui.Config.UserConfig.CommandTemplates.DownWithVolumes,
gui.DockerCommand.NewCommandObject(commands.CommandObject{}), gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
) )
options := []*commandOption{ options := []*commandOption{
@ -361,6 +415,10 @@ func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error {
return nil return nil
} }
if !gui.isServiceFromLocalProject(service) {
return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
}
rebuildCommand := utils.ApplyTemplate( rebuildCommand := utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.RebuildService, gui.Config.UserConfig.CommandTemplates.RebuildService,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}), gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}),
@ -465,8 +523,9 @@ L:
} }
func (gui *Gui) handleServicesBulkCommand(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleServicesBulkCommand(g *gocui.Gui, v *gocui.View) error {
project, _ := gui.Panels.Projects.GetSelectedItem()
bulkCommands := gui.Config.UserConfig.BulkCommands.Services 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) return gui.createBulkCommandMenu(bulkCommands, commandObject)
} }

View file

@ -120,6 +120,8 @@ func (gui *Gui) createAllViews() error {
gui.Views.Project.Title = gui.Tr.ProjectTitle gui.Views.Project.Title = gui.Tr.ProjectTitle
gui.Views.Project.TitlePrefix = "[1]" gui.Views.Project.TitlePrefix = "[1]"
gui.Views.Project.Highlight = true
gui.Views.Project.SelBgColor = selectedLineBgColor
gui.Views.Services.Highlight = true gui.Views.Services.Highlight = true
gui.Views.Services.Title = gui.Tr.ServicesTitle gui.Views.Services.Title = gui.Tr.ServicesTitle

View file

@ -121,6 +121,7 @@ type TranslationSet struct {
NothingToDisplay string NothingToDisplay string
NoContainerForService string NoContainerForService string
CannotDisplayEnvVariables string CannotDisplayEnvVariables string
CannotManageNonLocalService string
No string No string
Yes string Yes string
@ -241,6 +242,7 @@ func englishSet() TranslationSet {
NothingToDisplay: "Nothing to display", NothingToDisplay: "Nothing to display",
NoContainerForService: "No logs to show; service is not associated with a container", NoContainerForService: "No logs to show; service is not associated with a container",
CannotDisplayEnvVariables: "Something went wrong while displaying environment variables", 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", NoContainers: "No containers",
NoContainer: "No container", NoContainer: "No container",