Compare commits

..

No commits in common. "master" and "v0.24.4" have entirely different histories.

30 changed files with 135 additions and 511 deletions

View file

@ -1,15 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | { read -r f; case \"$f\" in *.go) gofumpt -w \"$f\" ;; esac; } 2>/dev/null || true"
}
]
}
]
}
}

View file

@ -1,5 +0,0 @@
# CLAUDE.md
## Build & test
- All Go commands need `GOFLAGS=-mod=vendor` (deps are vendored, including the `jesseduffield/gocui` fork and the Docker SDK).
- Unit tests: `GOFLAGS=-mod=vendor go test ./...`

View file

View file

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

View file

@ -11,7 +11,6 @@ _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,7 +11,6 @@ _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,7 +11,6 @@ _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,7 +11,6 @@ _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,7 +11,6 @@ _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,7 +11,6 @@ _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,7 +11,6 @@ _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,7 +11,6 @@ _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,7 +11,6 @@ _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,7 +29,6 @@ var (
configFlag = false
debuggingFlag = false
composeFiles []string
projectName string
)
func main() {
@ -52,7 +51,6 @@ 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()
@ -73,7 +71,7 @@ func main() {
log.Fatal(err.Error())
}
appConfig, err := config.NewAppConfig("lazydocker", version, commit, date, buildSource, debuggingFlag, composeFiles, projectDir, projectName)
appConfig, err := config.NewAppConfig("lazydocker", version, commit, date, buildSource, debuggingFlag, composeFiles, projectDir)
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,12 +57,6 @@ 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,8 +9,6 @@ import (
ogLog "log"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@ -41,11 +39,9 @@ type DockerCommand struct {
Config *config.AppConfig
Client *client.Client
InDockerComposeProject bool
// LocalProjectName is the compose project name for the directory where lazydocker was launched.
LocalProjectName string
ErrorChan chan error
ContainerMutex deadlock.Mutex
ServiceMutex deadlock.Mutex
ErrorChan chan error
ContainerMutex deadlock.Mutex
ServiceMutex deadlock.Mutex
Closers []io.Closer
}
@ -65,22 +61,12 @@ 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
}
@ -154,25 +140,9 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat
log.Warn(err.Error())
}
// When the user passes -p outside of a compose directory, treat it as the
// local project so the project/services panels still appear and filtering
// is applied. Inside a compose dir, LocalProjectName is derived from
// container labels later in RefreshContainersAndServices.
if !dockerCommand.InDockerComposeProject && config.ProjectName != "" {
dockerCommand.LocalProjectName = config.ProjectName
}
return dockerCommand, nil
}
// IsProjectScoped reports whether lazydocker should be scoped to a single
// compose project — either because we're inside a compose directory or
// because the user passed -p. When false, the project/services panels are
// hidden and all containers are shown in a flat list.
func (c *DockerCommand) IsProjectScoped() bool {
return c.InDockerComposeProject || c.Config.ProjectName != ""
}
func (c *DockerCommand) setDockerComposeCommand(config *config.AppConfig) {
if config.UserConfig.CommandTemplates.DockerCompose != "docker compose" {
return
@ -223,7 +193,7 @@ func (c *DockerCommand) CreateClientStatMonitor(container *Container) {
container.MonitoringStats = false
}
func (c *DockerCommand) RefreshContainersAndServices(currentContainers []*Container) ([]*Container, []*Service, error) {
func (c *DockerCommand) RefreshContainersAndServices(currentServices []*Service, currentContainers []*Container) ([]*Container, []*Service, error) {
c.ServiceMutex.Lock()
defer c.ServiceMutex.Unlock()
@ -232,136 +202,27 @@ func (c *DockerCommand) RefreshContainersAndServices(currentContainers []*Contai
return nil, nil, err
}
// Derive services from container labels (covers all projects)
services := c.GetServicesFromContainers(containers)
var composeServices []*Service
if c.InDockerComposeProject {
composeServices, err = c.GetServices()
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()
if err != nil {
c.Log.Warn("Failed to get compose services: " + err.Error())
return nil, nil, err
}
}
// 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 && ctr.ProjectName == service.ProjectName {
if !ctr.OneOff && ctr.ServiceName == service.Name {
service.Container = ctr
continue L
}
@ -450,8 +311,7 @@ func (c *DockerCommand) GetServices() ([]*Service, error) {
for i, str := range lines {
services[i] = &Service{
Name: str,
ID: c.LocalProjectName + "-" + str,
ProjectName: c.LocalProjectName,
ID: str,
OSCommand: c.OSCommand,
Log: c.Log,
DockerCommand: c,
@ -491,11 +351,11 @@ func (c *DockerCommand) SetContainerDetails(containers []*Container) {
}
// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose
func (c *DockerCommand) ViewAllLogs(project *Project) (*exec.Cmd, error) {
func (c *DockerCommand) ViewAllLogs() (*exec.Cmd, error) {
cmd := c.OSCommand.ExecutableFromString(
utils.ApplyTemplate(
c.OSCommand.Config.UserConfig.CommandTemplates.ViewAllLogs,
c.NewCommandObject(CommandObject{Project: project}),
c.NewCommandObject(CommandObject{}),
),
)
@ -506,15 +366,10 @@ func (c *DockerCommand) ViewAllLogs(project *Project) (*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{Project: project}),
c.NewCommandObject(CommandObject{}),
),
)
if err != nil {

View file

@ -5,7 +5,6 @@ import (
"testing"
"github.com/docker/docker/client"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/stretchr/testify/assert"
)
@ -62,30 +61,3 @@ func TestNewDockerClientVersionNegotiation(t *testing.T) {
"client version should not be locked to DOCKER_API_VERSION env var")
})
}
// TestIsProjectScoped covers the predicate that drives whether the
// project/services panels appear and whether the containers panel filters by
// project. The "outside compose dir + -p" case is the regression we fixed
// after PR #776 silently disabled it.
func TestIsProjectScoped(t *testing.T) {
cases := []struct {
name string
inDockerComposeProject bool
projectName string
want bool
}{
{"inside compose dir, no -p", true, "", true},
{"inside compose dir, with -p", true, "myproject", true},
{"outside compose dir, no -p", false, "", false},
{"outside compose dir, with -p", false, "myproject", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := &DockerCommand{
InDockerComposeProject: tc.inDockerComposeProject,
Config: &config.AppConfig{ProjectName: tc.projectName},
}
assert.Equal(t, tc.want, c.IsProjectScoped())
})
}
}

View file

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

View file

@ -488,11 +488,10 @@ 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, projectName string) (*AppConfig, error) {
func NewAppConfig(name, version, commit, date string, buildSource string, debuggingFlag bool, composeFiles []string, projectDir string) (*AppConfig, error) {
configDir, err := findOrCreateConfigDir(name)
if err != nil {
return nil, err
@ -518,7 +517,6 @@ 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,31 +175,14 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box {
return defaultBox
}
// The project panel is compact (Size: 3) when not focused, but expands
// when focused to show the list of projects. This only applies when the
// project panel is actually visible (i.e. we are inside a compose project).
if len(sideWindowNames) > 0 && sideWindowNames[0] == "project" {
projectBox := &boxlayout.Box{
return append([]*boxlayout.Box{
{
Window: sideWindowNames[0],
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 {
return accordionBox(&boxlayout.Box{Window: window, Weight: 1})
})...)
}
return lo.Map(sideWindowNames, func(window string, _ int) *boxlayout.Box {
},
}, lo.Map(sideWindowNames[1:], func(window string, _ int) *boxlayout.Box {
return accordionBox(&boxlayout.Box{Window: window, Weight: 1})
})
})...)
} else {
squashedHeight := 1
if height >= 21 {

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 && service.ProjectName == container.ProjectName
return service.Name == container.ServiceName
})
}
@ -83,35 +83,15 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container]
return sortContainers(a, b, gui.Config.UserConfig.Gui.LegacySortContainers)
},
Filter: func(container *commands.Container) bool {
if !gui.State.ShowExitedContainers && container.Container.State == "exited" {
// Note that this is O(N*M) time complexity where N is the number of services
// and M is the number of containers. We expect N to be small but M may be large,
// so we will need to keep an eye on this.
if !gui.Config.UserConfig.Gui.ShowAllContainers && !isStandaloneContainer(container) {
return false
}
// When project-scoped, apply project and standalone filtering.
// Otherwise all containers are shown in a flat list regardless
// of which compose project they belong to.
if gui.DockerCommand.IsProjectScoped() {
// This check must be inside the IsProjectScoped guard: when
// not project-scoped, services are still derived from container
// labels, so compose-managed containers from other projects
// would be incorrectly hidden.
//
// Note that this is O(N*M) time complexity where N is the number of services
// and M is the number of containers. We expect N to be small but M may be large,
// so we will need to keep an eye on this.
if !gui.Config.UserConfig.Gui.ShowAllContainers && !isStandaloneContainer(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
}
if !gui.State.ShowExitedContainers && container.Container.State == "exited" {
return false
}
return true
@ -276,6 +256,7 @@ 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 {
@ -302,8 +283,10 @@ func (gui *Gui) refreshContainersAndServices() error {
}
func (gui *Gui) renderContainersAndServices() error {
if err := gui.Panels.Services.RerenderList(); err != nil {
return err
if gui.DockerCommand.InDockerComposeProject {
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() {
// Refresh containers/services first, then projects (which depend on
// container labels to discover projects).
if err := gui.refreshContainersAndServices(); err != nil {
if err := gui.refreshProject(); err != nil {
gui.Log.Error(err)
}
if err := gui.refreshProject(); err != nil {
}()
go func() {
if err := gui.refreshContainersAndServices(); err != nil {
gui.Log.Error(err)
}
}()
@ -459,7 +459,7 @@ func (gui *Gui) ShouldRefresh(key string) bool {
}
func (gui *Gui) initiallyFocusedViewName() string {
if gui.DockerCommand.IsProjectScoped() {
if gui.DockerCommand.InDockerComposeProject {
return "services"
}
return "containers"

View file

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

View file

@ -47,9 +47,6 @@ 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
@ -119,12 +116,6 @@ 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,6 +3,7 @@ package gui
import (
"bytes"
"context"
"path"
"strings"
"github.com/fatih/color"
@ -15,21 +16,34 @@ 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,
@ -50,91 +64,31 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] {
Gui: gui.intoInterface(),
Sort: func(a *commands.Project, b *commands.Project) bool {
return a.Name < b.Name
return false
},
GetTableCells: presentation.GetProjectDisplayStrings,
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()
},
Hide: func() bool {
return !gui.DockerCommand.IsProjectScoped()
},
// It doesn't make sense to filter a list of only one item.
DisableFilter: true,
}
}
func (gui *Gui) refreshProject() error {
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
}
}
}
gui.Panels.Projects.SetItems([]*commands.Project{{Name: gui.getProjectName()}})
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, or from -p) is
// included even when it has no running containers, so the user always sees the project
// they explicitly scoped to.
func (gui *Gui) getDiscoveredProjects() []*commands.Project {
containers := gui.Panels.Containers.List.GetAllItems()
projectNames := gui.DockerCommand.GetProjectNames(containers)
// If we're scoped to a 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.IsProjectScoped() && localName != "" {
found := false
for _, name := range projectNames {
if name == localName {
found = true
break
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"]
}
}
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
return projectName
}
func (gui *Gui) renderCredits(_project *commands.Project) tasks.TaskFunc {
@ -158,7 +112,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,
@ -168,7 +122,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{Project: project}),
gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
),
)
@ -190,19 +144,9 @@ func (gui *Gui) renderAllLogs(project *commands.Project) tasks.TaskFunc {
})
}
func (gui *Gui) renderDockerComposeConfig(project *commands.Project) tasks.TaskFunc {
if !gui.DockerCommand.InDockerComposeProject {
return gui.NewSimpleRenderStringTask(func() string {
return "Compose config is only available when launched from a docker-compose project directory"
})
}
if project != nil && project.Name != gui.DockerCommand.LocalProjectName {
return gui.NewSimpleRenderStringTask(func() string {
return "Compose config is not available for non-local projects"
})
}
func (gui *Gui) renderDockerComposeConfig(_project *commands.Project) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string {
return utils.ColoredYamlString(gui.DockerCommand.DockerComposeConfigForProject(project))
return utils.ColoredYamlString(gui.DockerCommand.DockerComposeConfig())
})
}
@ -229,8 +173,7 @@ func lazydockerTitle() string {
// handleViewAllLogs switches to a subprocess viewing all the logs from docker-compose
func (gui *Gui) handleViewAllLogs(g *gocui.Gui, v *gocui.View) error {
project, _ := gui.Panels.Projects.GetSelectedItem()
c, err := gui.DockerCommand.ViewAllLogs(project)
c, err := gui.DockerCommand.ViewAllLogs()
if err != nil {
return gui.createErrorPanel(err.Error())
}

View file

@ -74,23 +74,11 @@ 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.IsProjectScoped()
return !gui.DockerCommand.InDockerComposeProject
},
}
}
@ -154,24 +142,13 @@ 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
}
if !gui.isServiceFromLocalProject(service) {
return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
}
composeCommand := gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}).DockerCompose
composeCommand := gui.Config.UserConfig.CommandTemplates.DockerCompose
options := []*commandOption{
{
@ -225,15 +202,10 @@ 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)
@ -245,10 +217,6 @@ 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())
@ -265,15 +233,10 @@ 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
})
}
@ -284,19 +247,11 @@ 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
})
}
@ -334,14 +289,10 @@ 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{Project: project}),
gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
)
return gui.WithWaitingStatus(gui.Tr.UppingProjectStatus, func() error {
@ -354,18 +305,14 @@ 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{Project: project}),
gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
)
downWithVolumesCommand := utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.DownWithVolumes,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
)
options := []*commandOption{
@ -414,10 +361,6 @@ 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}),
@ -522,9 +465,8 @@ L:
}
func (gui *Gui) handleServicesBulkCommand(g *gocui.Gui, v *gocui.View) error {
project, _ := gui.Panels.Projects.GetSelectedItem()
bulkCommands := gui.Config.UserConfig.BulkCommands.Services
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project})
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{})
return gui.createBulkCommandMenu(bulkCommands, commandObject)
}

View file

@ -120,8 +120,6 @@ 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
@ -130,7 +128,7 @@ func (gui *Gui) createAllViews() error {
gui.Views.Containers.Highlight = true
gui.Views.Containers.SelBgColor = selectedLineBgColor
if gui.Config.UserConfig.Gui.ShowAllContainers || !gui.DockerCommand.IsProjectScoped() {
if gui.Config.UserConfig.Gui.ShowAllContainers || !gui.DockerCommand.InDockerComposeProject {
gui.Views.Containers.Title = gui.Tr.ContainersTitle
} else {
gui.Views.Containers.Title = gui.Tr.StandaloneContainersTitle

View file

@ -110,18 +110,17 @@ 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
CannotManageNonLocalService string
LogsTitle string
ConfigTitle string
EnvTitle string
DockerComposeConfigTitle string
StatsTitle string
CreditsTitle string
ContainerConfigTitle string
ContainerEnvTitle string
NothingToDisplay string
NoContainerForService string
CannotDisplayEnvVariables string
No string
Yes string
@ -218,31 +217,30 @@ 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",
CannotManageNonLocalService: "This service belongs to a different compose project. Run lazydocker from that project's directory to manage it.",
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",
NoContainers: "No containers",
NoContainer: "No container",