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:
lazydocker:
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>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<kbd>/</kbd>: filter list
</pre>
## 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>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<kbd>/</kbd>: filter list
</pre>
## 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>[</kbd>: anterior pestaña
<kbd>]</kbd>: siguiente pestaña
<kbd>/</kbd>: filtrar lista
</pre>
## 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>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## 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>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<kbd>/</kbd>: filter list
</pre>
## 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>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<kbd>/</kbd>: filter list
</pre>
## 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>[</kbd>: aba anterior
<kbd>]</kbd>: próxima aba
<kbd>/</kbd>: filtrar lista
</pre>
## 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>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<kbd>/</kbd>: filter list
</pre>
## 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>[</kbd>: 上一个选项卡
<kbd>]</kbd>: 下一个选项卡
<kbd>/</kbd>: 过滤列表
</pre>
## 容器

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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))

View file

@ -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 {

View file

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

View file

@ -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

View file

@ -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)
}

View file

@ -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})
})...)

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 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 {

View file

@ -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)
}
}()

View file

@ -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
}
}
}

View file

@ -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)
}

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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

View file

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