Compare commits

...

14 commits

Author SHA1 Message Date
Jesse Duffield
7e7aadc207
Merge pull request #797 from jesseduffield/support-p-flag
Support -p flag again
2026-04-19 12:50:06 +10:00
Jesse Duffield
f5ff116af9 Use IsProjectScoped at remaining call sites + add test
Addresses Copilot review comments on PR #797:
- Containers panel title, initial focus selection, and the local-project
  inclusion in getDiscoveredProjects all switched from
  InDockerComposeProject to IsProjectScoped, so they stay consistent
  when -p is given outside a compose dir.
- Adds TestIsProjectScoped table test covering all four combinations.

InDockerComposeProject is still used at the call sites that genuinely
require a compose dir (renderDockerComposeConfig, GetServices).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:47:30 +10:00
Jesse Duffield
3974f6fec1 Support -p flag and DRY up code 2026-04-19 12:16:55 +10:00
Jesse Duffield
697cd441aa Add some claude stuff 2026-04-19 12:16:43 +10:00
Jesse Duffield
9134abeefd
Merge pull request #776 from jesseduffield/copilot/disable-forced-project-view
Hide project/services panels when not in a docker-compose project directory
2026-04-19 11:55:51 +10:00
Jesse Duffield
8106125441
Merge pull request #795 from ddibiasi/copilot/disable-forced-project-view
Fixed forced project view
2026-04-19 11:55:00 +10:00
Daniel Dibiasi
b17d474148
Fixed forced project view 2026-04-16 09:23:12 +02:00
copilot-swe-agent[bot]
e3c1c8630a Hide project/services panels when not in a docker-compose project
Co-authored-by: jesseduffield <8456633+jesseduffield@users.noreply.github.com>
2026-03-15 22:26:03 +00:00
copilot-swe-agent[bot]
ebce4fc631 Initial plan 2026-03-15 22:20:36 +00:00
Jesse Duffield
2ebe3848d6
Merge pull request #774 from jesseduffield/better-project-support
Improve projects support
2026-03-15 14:55:35 +11:00
Jesse Duffield
b65137addf Add claude settings file 2026-03-15 14:53:48 +11:00
Jesse Duffield
26ce871368 Improve projects support 2026-03-15 14:53:48 +11:00
Jesse Duffield
577797d9ed
Merge pull request #744 from jesseduffield/fix-version-lock
Fix version lock issue
2026-01-17 17:14:40 +11:00
Jesse Duffield
3c9d134d05 Fix version lock issue 2026-01-17 17:03:34 +11:00
30 changed files with 592 additions and 138 deletions

15
.claude/settings.json Normal file
View file

@ -0,0 +1,15 @@
{
"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"
}
]
}
]
}
}

5
CLAUDE.md Normal file
View file

@ -0,0 +1,5 @@
# 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 ./...`

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,9 +41,11 @@ type DockerCommand struct {
Config *config.AppConfig Config *config.AppConfig
Client *client.Client Client *client.Client
InDockerComposeProject bool InDockerComposeProject bool
ErrorChan chan error // LocalProjectName is the compose project name for the directory where lazydocker was launched.
ContainerMutex deadlock.Mutex LocalProjectName string
ServiceMutex deadlock.Mutex ErrorChan chan error
ContainerMutex deadlock.Mutex
ServiceMutex deadlock.Mutex
Closers []io.Closer Closers []io.Closer
} }
@ -61,15 +65,40 @@ 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
} }
// newDockerClient creates a Docker client with the given host.
// We avoid using client.FromEnv because it includes WithVersionFromEnv() which
// sets manualOverride=true when DOCKER_API_VERSION is set, preventing API version
// negotiation even when WithAPIVersionNegotiation() is specified.
// Instead, we explicitly configure only what we need, and rely on proper
// API version negotiation to support older Docker daemons.
// See https://github.com/jesseduffield/lazydocker/issues/715
func newDockerClient(dockerHost string) (*client.Client, error) {
return client.NewClientWithOpts(
client.WithTLSClientConfigFromEnv(),
client.WithAPIVersionNegotiation(),
client.WithHost(dockerHost),
)
}
// NewDockerCommand it runs docker commands // NewDockerCommand it runs docker commands
func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*DockerCommand, error) { func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*DockerCommand, error) {
dockerHost, err := determineDockerHost() dockerHost, err := determineDockerHost()
@ -96,7 +125,7 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat
dockerHost = dockerHostFromEnv dockerHost = dockerHostFromEnv
} }
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation(), client.WithHost(dockerHost)) cli, err := newDockerClient(dockerHost)
if err != nil { if err != nil {
ogLog.Fatal(err) ogLog.Fatal(err)
} }
@ -125,9 +154,25 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat
log.Warn(err.Error()) 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 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) { func (c *DockerCommand) setDockerComposeCommand(config *config.AppConfig) {
if config.UserConfig.CommandTemplates.DockerCompose != "docker compose" { if config.UserConfig.CommandTemplates.DockerCompose != "docker compose" {
return return
@ -178,7 +223,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()
@ -187,27 +232,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
} }
@ -296,7 +450,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,
@ -336,11 +491,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}),
), ),
) )
@ -351,10 +506,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

@ -0,0 +1,91 @@
package commands
import (
"os"
"testing"
"github.com/docker/docker/client"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/stretchr/testify/assert"
)
// TestNewDockerClientVersionNegotiation verifies that newDockerClient allows
// API version negotiation even when DOCKER_API_VERSION is set.
//
// This is a regression test for https://github.com/jesseduffield/lazydocker/issues/715
// where users got "client version 1.25 is too old" errors because FromEnv()
// includes WithVersionFromEnv() which sets manualOverride=true, preventing
// API version negotiation.
func TestNewDockerClientVersionNegotiation(t *testing.T) {
// Save original env var and restore after test
originalAPIVersion := os.Getenv("DOCKER_API_VERSION")
defer func() {
if originalAPIVersion == "" {
os.Unsetenv("DOCKER_API_VERSION")
} else {
os.Setenv("DOCKER_API_VERSION", originalAPIVersion)
}
}()
// Set DOCKER_API_VERSION to an old version that would cause
// "client version 1.25 is too old" errors if negotiation is disabled
os.Setenv("DOCKER_API_VERSION", "1.25")
t.Run("FromEnv locks version preventing negotiation", func(t *testing.T) {
// This demonstrates the problematic behavior we're avoiding.
// When using FromEnv with DOCKER_API_VERSION set, the client
// version gets locked to 1.25 and negotiation is disabled.
cli, err := client.NewClientWithOpts(
client.FromEnv,
client.WithAPIVersionNegotiation(),
)
assert.NoError(t, err)
defer cli.Close()
// Version is locked to the env var value
assert.Equal(t, "1.25", cli.ClientVersion())
})
t.Run("newDockerClient allows version negotiation", func(t *testing.T) {
// Test the actual production function.
// Use DefaultDockerHost for cross-platform compatibility
// (unix socket on Linux/macOS, named pipe on Windows).
cli, err := newDockerClient(client.DefaultDockerHost)
assert.NoError(t, err)
defer cli.Close()
// Version is NOT locked to the env var value (1.25).
// Instead, it uses the library's default version and will negotiate
// with the server on first request. This is the key difference that
// fixes the "version too old" error.
assert.NotEqual(t, "1.25", cli.ClientVersion(),
"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,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,14 +175,31 @@ 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. 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{
Window: sideWindowNames[0], Window: sideWindowNames[0],
Size: 3, Size: 3,
}, }
}, lo.Map(sideWindowNames[1:], func(window string, _ int) *boxlayout.Box { 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 {
return accordionBox(&boxlayout.Box{Window: window, Weight: 1}) return accordionBox(&boxlayout.Box{Window: window, Weight: 1})
})...) })
} else { } else {
squashedHeight := 1 squashedHeight := 1
if height >= 21 { 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 !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
}) })
} }
@ -83,15 +83,35 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container]
return sortContainers(a, b, gui.Config.UserConfig.Gui.LegacySortContainers) return sortContainers(a, b, gui.Config.UserConfig.Gui.LegacySortContainers)
}, },
Filter: func(container *commands.Container) bool { Filter: func(container *commands.Container) bool {
// Note that this is O(N*M) time complexity where N is the number of services if !gui.State.ShowExitedContainers && container.Container.State == "exited" {
// 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 return false
} }
if !gui.State.ShowExitedContainers && container.Container.State == "exited" { // When project-scoped, apply project and standalone filtering.
return false // 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
}
} }
return true return true
@ -256,7 +276,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,10 +302,8 @@ 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 {

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)
} }
}() }()
@ -459,7 +459,7 @@ func (gui *Gui) ShouldRefresh(key string) bool {
} }
func (gui *Gui) initiallyFocusedViewName() string { func (gui *Gui) initiallyFocusedViewName() string {
if gui.DockerCommand.InDockerComposeProject { if gui.DockerCommand.IsProjectScoped() {
return "services" return "services"
} }
return "containers" return "containers"

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,34 +15,21 @@ 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]{
{
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]{ 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", Key: "credits",
Title: gui.Tr.CreditsTitle, Title: gui.Tr.CreditsTitle,
@ -64,31 +50,91 @@ 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()
},
Hide: func() bool {
return !gui.DockerCommand.IsProjectScoped()
},
} }
} }
func (gui *Gui) refreshProject() error { func (gui *Gui) refreshProject() error {
gui.Panels.Projects.SetItems([]*commands.Project{{Name: gui.getProjectName()}}) projects := gui.getDiscoveredProjects()
return gui.Panels.Projects.RerenderList()
}
func (gui *Gui) getProjectName() string { // Preserve the current selection across refreshes. On the first refresh,
projectName := path.Base(gui.Config.ProjectDir) // select the project specified via -p flag, or fall back to the local project.
if gui.DockerCommand.InDockerComposeProject { selectedName := gui.getSelectedProjectName()
for _, service := range gui.Panels.Services.List.GetAllItems() { if selectedName == "" {
container := service.Container if gui.Config.ProjectName != "" {
if container != nil && container.DetailsLoaded() { selectedName = gui.Config.ProjectName
return container.Details.Config.Labels["com.docker.compose.project"] } 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, 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
}
}
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 { func (gui *Gui) renderCredits(_project *commands.Project) tasks.TaskFunc {
@ -112,7 +158,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 +168,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 +190,19 @@ 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 !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"
})
}
return gui.NewSimpleRenderStringTask(func() string { return gui.NewSimpleRenderStringTask(func() string {
return utils.ColoredYamlString(gui.DockerCommand.DockerComposeConfig()) return utils.ColoredYamlString(gui.DockerCommand.DockerComposeConfigForProject(project))
}) })
} }
@ -173,7 +229,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,23 @@ 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 return !gui.DockerCommand.IsProjectScoped()
}, },
} }
} }
@ -142,13 +154,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 +225,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 +245,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 +265,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 +284,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 +334,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 +354,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 +414,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 +522,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
@ -128,7 +130,7 @@ func (gui *Gui) createAllViews() error {
gui.Views.Containers.Highlight = true gui.Views.Containers.Highlight = true
gui.Views.Containers.SelBgColor = selectedLineBgColor gui.Views.Containers.SelBgColor = selectedLineBgColor
if gui.Config.UserConfig.Gui.ShowAllContainers || !gui.DockerCommand.InDockerComposeProject { if gui.Config.UserConfig.Gui.ShowAllContainers || !gui.DockerCommand.IsProjectScoped() {
gui.Views.Containers.Title = gui.Tr.ContainersTitle gui.Views.Containers.Title = gui.Tr.ContainersTitle
} else { } else {
gui.Views.Containers.Title = gui.Tr.StandaloneContainersTitle gui.Views.Containers.Title = gui.Tr.StandaloneContainersTitle

View file

@ -110,17 +110,18 @@ type TranslationSet struct {
OpenInBrowser string OpenInBrowser string
SortContainersByState string SortContainersByState string
LogsTitle string LogsTitle string
ConfigTitle string ConfigTitle string
EnvTitle string EnvTitle string
DockerComposeConfigTitle string DockerComposeConfigTitle string
StatsTitle string StatsTitle string
CreditsTitle string CreditsTitle string
ContainerConfigTitle string ContainerConfigTitle string
ContainerEnvTitle string ContainerEnvTitle string
NothingToDisplay string NothingToDisplay string
NoContainerForService string NoContainerForService string
CannotDisplayEnvVariables string CannotDisplayEnvVariables string
CannotManageNonLocalService string
No string No string
Yes string Yes string
@ -217,30 +218,31 @@ func englishSet() TranslationSet {
OpenInBrowser: "open in browser (first port is http)", OpenInBrowser: "open in browser (first port is http)",
SortContainersByState: "sort containers by state", SortContainersByState: "sort containers by state",
GlobalTitle: "Global", GlobalTitle: "Global",
MainTitle: "Main", MainTitle: "Main",
ProjectTitle: "Project", ProjectTitle: "Project",
ServicesTitle: "Services", ServicesTitle: "Services",
ContainersTitle: "Containers", ContainersTitle: "Containers",
StandaloneContainersTitle: "Standalone Containers", StandaloneContainersTitle: "Standalone Containers",
ImagesTitle: "Images", ImagesTitle: "Images",
VolumesTitle: "Volumes", VolumesTitle: "Volumes",
NetworksTitle: "Networks", NetworksTitle: "Networks",
CustomCommandTitle: "Custom Command:", CustomCommandTitle: "Custom Command:",
BulkCommandTitle: "Bulk Command:", BulkCommandTitle: "Bulk Command:",
ErrorTitle: "Error", ErrorTitle: "Error",
LogsTitle: "Logs", LogsTitle: "Logs",
ConfigTitle: "Config", ConfigTitle: "Config",
EnvTitle: "Env", EnvTitle: "Env",
DockerComposeConfigTitle: "Docker-Compose Config", DockerComposeConfigTitle: "Docker-Compose Config",
TopTitle: "Top", TopTitle: "Top",
StatsTitle: "Stats", StatsTitle: "Stats",
CreditsTitle: "About", CreditsTitle: "About",
ContainerConfigTitle: "Container Config", ContainerConfigTitle: "Container Config",
ContainerEnvTitle: "Container Env", ContainerEnvTitle: "Container Env",
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",