Compare commits

..

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

1637 changed files with 110327 additions and 318840 deletions

View file

@ -1,81 +1,55 @@
version: 2
# global environment variables
# '& ' syntax is a YAML thing for creating referenceable documents
env-shared: &env-shared
environment:
GOFLAGS: "-mod=vendor"
# build steps shared across different jobs
build-shared: &build-shared
steps:
- checkout
- run:
name: Run gofmt -s
command: |
if [ $(find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;|wc -l) -gt 0 ]; then
find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;
exit 1;
fi
- restore_cache:
keys:
- pkg-cache-{{ checksum "go.sum" }}-v5
- run:
name: Run tests
command: |
go test -v ./...
- run:
name: Get gox
# -mod=vendor doesn't work with go get while in source tree with go.mod in it
working_directory: "/tmp"
command: |
go get github.com/mitchellh/gox
- run:
name: Compile project on every platform
command: |
gox -parallel 10 -os "linux freebsd" -osarch "darwin/i386 darwin/amd64 windows/i386 windows/amd64"
- save_cache:
key: pkg-cache-{{ checksum "go.sum" }}-v5
paths:
- ~/.cache/go-build
jobs:
# matrix build jobs run on every commit
# building on versioned Go and the latest one
# '<<: *' syntax is a YAML thing for including documents
"golang:1.17":
<<: *env-shared
<<: *build-shared
build:
docker:
- image: circleci/golang:1.17
"golang:latest":
<<: *env-shared
<<: *build-shared
docker:
- image: circleci/golang:latest
# release job run only when a commit is tagged
- image: circleci/golang:1.12
working_directory: /go/src/github.com/jesseduffield/lazydocker
steps:
- checkout
- run:
name: Run gofmt -s
command: |
if [ $(find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;|wc -l) -gt 0 ]; then
find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;
exit 1;
fi
- restore_cache:
keys:
- pkg-cache-{{ checksum "Gopkg.lock" }}-v4
- run:
name: Run tests
command: |
./test.sh
- run:
name: Push on codecov result
command: |
bash <(curl -s https://codecov.io/bash)
- run:
name: Compile project on every platform
command: |
go get github.com/mitchellh/gox
gox -parallel 10 -os "linux freebsd" -osarch "darwin/i386 darwin/amd64"
- save_cache:
key: pkg-cache-{{ checksum "Gopkg.lock" }}-v4
paths:
- ~/.cache/go-build
release:
<<: *env-shared
docker:
- image: circleci/golang:1.17
- image: circleci/golang:1.10
working_directory: /go/src/github.com/jesseduffield/lazydocker
steps:
- checkout
- run:
name: Run gorelease
command: |
curl -sL https://git.io/goreleaser | bash
- run:
name: Update docs
command: |
./.circleci/update_docs.sh
workflows:
version: 2
build:
jobs:
# build matrix
- "golang:1.17"
- "golang:latest"
- build
release:
jobs:
- release:

View file

@ -1,25 +0,0 @@
#!/bin/bash
sudo apt-get update
sudo apt-get install -y \
squashfs-tools \
python3-pip \
python3-apt \
python3-debian \
python3-pyelftools \
python3-yaml \
python3-tabulate \
python3-jsonschema \
python3-click \
python3-pymacaroons \
python3-simplejson \
python3-progressbar \
python3-requests-toolbelt \
python3-requests-unixsocket
sudo pip3 install \
petname \
snapcraft
snapcraft --version

View file

@ -1,24 +0,0 @@
#!/bin/bash
set -ex
# see if we have a new cheatsheet
# if other docs end up being generated automatically we can chuck in the relevant scripts here
go run scripts/generate_cheatsheet.go
# commit and push if we have a change
if [[ -z $(git status -s -- docs/*) ]]; then
echo "no changes to commit in the docs directory"
exit 0
fi
echo "committing updated docs"
git config user.name "lazydocker bot"
git config user.email "jessedduffield@gmail.com"
git checkout master # just making sure we're up to date
git pull
git add docs/*
git commit -m "update docs"
git push -u origin master

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,11 +0,0 @@
FROM mcr.microsoft.com/devcontainers/go:bullseye
RUN apt-get update && apt-get install -y \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
RUN go install mvdan.cc/gofumpt@latest
ENV PATH="/root/go/bin:${PATH}"
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.50.0
RUN golangci-lint --version

View file

@ -1,54 +0,0 @@
{
"name": "Docker in Docker",
"build": {
"dockerfile": "./Dockerfile",
"context": "."
},
"features": {
"ghcr.io/devcontainers/features/common-utils:1": {
"installZsh": "true",
"upgradePackages": "false",
"uid": "1000",
"gid": "1000",
"installOhMyZsh": "true",
"nonFreePackages": "true"
},
"ghcr.io/devcontainers/features/docker-from-docker:1": {
"version": "latest",
"enableNonRootDocker": "true",
"moby": "true"
},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/go:1": {}
},
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Set *default* container specific settings.json values on container create.
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "~/go",
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
},
"go.lintTool": "golangci-lint",
"gopls": {
"formatting.gofumpt": true,
"usePlaceholders": false // add parameter placeholders when completing a function
},
"files.eol": "\n"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go"
]
}
}
// TODO: make this work.
// "postStartCommand": "echo \"alias gr=\\\"go run /workspaces/lazydocker/main.go\\\"\" >> ~/.zhsrc"
}

View file

@ -1,12 +0,0 @@
.circleci
.github
docs
test
.gitignore
.goreleaser.yml
*.md
coverage.txt
Dockerfile
LICENSE
test.sh
.git

4
.github/FUNDING.yml vendored
View file

@ -1,4 +0,0 @@
# These are supported funding model platforms
github: [jesseduffield]
custom: ['https://donorbox.org/lazygit']

View file

@ -1,32 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behaviour**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. Windows]
- Lazydocker Version [e.g. v0.1.45]
- The last commit id if you built project from sources (run : ```git rev-parse HEAD```)
**Additional context**
Add any other context about the problem here.

View file

@ -1,10 +0,0 @@
---
name: Discussion
about: For starting a discussion
title: ''
labels: discussion
assignees: ''
---

View file

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -1,27 +0,0 @@
name: Continuous Delivery
on:
push:
tags:
- "v*"
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Unshallow repo
run: git fetch --prune --unshallow
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: 1.24.x
- name: Run goreleaser
uses: goreleaser/goreleaser-action@v1
with:
distribution: goreleaser
version: v1.17.2
args: release --clean
env:
GITHUB_TOKEN: ${{secrets.TOKEN_GITHUB}}

View file

@ -1,127 +0,0 @@
name: Continuous Integration
env:
GO_VERSION: 1.24
on:
push:
branches:
- master
pull_request:
jobs:
ci:
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
name: ci - ${{matrix.os}}
runs-on: ${{matrix.os}}
env:
GOFLAGS: -mod=vendor
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: 1.24.x
- name: Cache build
uses: actions/cache@v4
with:
path: ~/.cache/go-build
key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-test
restore-keys: |
${{runner.os}}-go-
- name: Test code
run: |
bash ./test.sh
build:
runs-on: ubuntu-latest
env:
GOFLAGS: -mod=vendor
GOARCH: amd64
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: 1.24.x
- name: Cache build
uses: actions/cache@v4
with:
path: ~/.cache/go-build
key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-build
restore-keys: |
${{runner.os}}-go-
- name: Build linux binary
run: |
GOOS=linux go build
- name: Build windows binary
run: |
GOOS=windows go build
- name: Build darwin binary
run: |
GOOS=darwin go build
check-codebase:
runs-on: ubuntu-latest
env:
GOFLAGS: -mod=vendor
GOARCH: amd64
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: 1.24.x
- name: Cache build
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-build
restore-keys: |
${{runner.os}}-go-
- name: Check Cheatsheet
run: |
go run scripts/cheatsheet/main.go check
- name: Check Vendor Directory
# ensure our vendor directory matches up with our go modules
run: |
go mod vendor && git diff --exit-code || (echo "Unexpected change to vendor directory. Run 'go mod vendor' locally and commit the changes" && exit 1)
lint:
runs-on: ubuntu-latest
env:
GOFLAGS: -mod=vendor
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: 1.24.x
- name: Cache build
uses: actions/cache@v4
with:
path: ~/.cache/go-build
key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-test
restore-keys: |
${{runner.os}}-go-
- name: Lint
uses: golangci/golangci-lint-action@v3.1.0
with:
version: latest
- name: Format code
run: |
if [ $(find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;|wc -l) -gt 0 ]; then
find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;
exit 1
fi
- name: errors
run: golangci-lint run
if: ${{ failure() }}

View file

@ -1,28 +0,0 @@
# see https://github.com/JamesIves/github-sponsors-readme-action
name: Generate Sponsors README
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v3
- name: Generate Sponsors 💖
uses: JamesIves/github-sponsors-readme-action@v1.2.2
with:
token: ${{ secrets.TOKEN_GITHUB }}
file: "README.md"
if: ${{ github.repository == 'jesseduffield/lazydocker' }}
- name: Create Pull Request 🚀
uses: peter-evans/create-pull-request@v6
with:
commit-message: "README.md: Update Sponsors"
title: "README.md: Update Sponsors"
author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
labels: "ignore-for-release"
delete-branch: true

4
.gitignore vendored
View file

@ -1,5 +1,3 @@
lazydocker*
TODO.md
Lazydocker.code-workspace
.vscode
.idea

View file

@ -1,27 +0,0 @@
linters:
enable:
- gofumpt
- thelper
- goimports
- tparallel
- wastedassign
- unparam
- prealloc
- unconvert
- exhaustive
- makezero
- nakedret
# - goconst # TODO: enable and fix issues
fast: false
linters-settings:
exhaustive:
default-signifies-exhaustive: true
nakedret:
# the gods will judge me but I just don't like naked returns at all
max-func-lines: 0
run:
go: '1.21'
timeout: 10m

View file

@ -1,14 +1,10 @@
# This is an example goreleaser.yaml file with some sane defaults.
# Make sure to check the documentation at http://goreleaser.com
env:
- CGO_ENABLED=0
- GOFLAGS=-mod=vendor
- GO111MODULE=auto
builds:
- id: binary
- env:
- CGO_ENABLED=0
goos:
# - freebsd
- freebsd
- windows
- darwin
- linux
@ -17,116 +13,40 @@ builds:
- arm
- arm64
- 386
goarm:
- 6
- 7
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.buildSource=binaryRelease
- id: snap
goos:
- linux
goarch:
- amd64
- arm
- arm64
- 386
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.buildSource=snap
archives:
- builds:
- binary
replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: x86
amd64: x86_64
format_overrides:
- goos: windows
format: zip
archive:
replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: 32-bit
amd64: x86_64
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"
name_template: '{{ .Tag }}-next'
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^bump"
- '^docs:'
- '^test:'
- '^bump'
brew:
# Reporitory to push the tap to.
github:
owner: jesseduffield
name: homebrew-lazydocker
brews:
- tap:
owner: jesseduffield
name: homebrew-lazydocker
# Your app's homepage.
# Default is empty.
homepage: 'https://github.com/jesseduffield/lazydocker/'
# Your app's homepage.
# Default is empty.
homepage: "https://github.com/jesseduffield/lazydocker/"
# Your app's description.
# Default is empty.
description: "A simple terminal UI for docker, written in Go"
#snapcrafts:
# - builds:
# - snap
#
# replacements:
# linux: Linux
# 386: x86
# amd64: x86_64
#
# # Wether to publish the snap to the snapcraft store.
# # Remember you need to `snapcraft login` first.
# # Defaults to false.
# publish: false
#
# # Single-line elevator pitch for your amazing snap.
# # 79 char long at most.
# summary: The lazier way to manage everything docker
#
# # This the description of your snap. You have a paragraph or two to tell the
# # most important story about your snap. Keep it under 100 words though,
# # we live in tweetspace and your description wants to look good in the snap
# # store.
# description: 'A simple terminal UI for docker, written in Go'
#
# # A guardrail to prevent you from releasing a snap to all your users before
# # it is ready.
# # `devel` will let you release only to the `edge` and `beta` channels in the
# # store. `stable` will let you release also to the `candidate` and `stable`
# # channels. More info about channels here:
# # https://snapcraft.io/docs/reference/channels
# # TODO: reset to `stable` when we've been manually reviewed: https://forum.snapcraft.io/t/request-for-classic-confinement-for-lazydocker/12155
# grade: devel
#
# # Snaps can be setup to follow three different confinement policies:
# # `strict`, `devmode` and `classic`. A strict confinement where the snap
# # can only read and write in its own namespace is recommended. Extra
# # permissions for strict snaps can be declared as `plugs` for the app, which
# # are explained later. More info about confinement here:
# # https://snapcraft.io/docs/reference/confinement
# confinement: classic
#
# # Your app's license, based on SPDX license expressions: https://spdx.org/licenses
# # Default is empty.
# license: MIT
#
# # # Each binary built by GoReleaser is an app inside the snap. In this section
# # # you can declare extra details for those binaries. It is optional.
# # apps:
#
# # # The name of the app must be the same name as the binary built or the snapcraft name.
# # lazydocker:
#
# # # If your app requires extra permissions to work outside of its default
# # # confined space, declare them here.
# # # You can read the documentation about the available plugs and the
# # # things they allow:
# # # https://snapcraft.io/docs/reference/interfaces.
# # plugs: []
# Your app's description.
# Default is empty.
description: 'A simple terminal UI for docker, written in Go'

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

@ -1,6 +1,6 @@
# Contributing
Wanna learn Go? This is the project for you!
♥ We love pull requests from everyone !
When contributing to this repository, please first discuss the change you wish
to make via issue, email, or any other method with the owners of this repository
@ -19,42 +19,6 @@ welcome your pull requests:
6. Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
7. Issue that pull request!
## Vendoring
We use a vendor directory to store all dependent files. A vendor directory ensures a single source of truth, so that it's clear in each PR what changes are being made, as well as allowing quick testing-out of ideas across various dependent package, or searching the files in your dependent packages via your editor.
BUT this currently comes at a cost. I have begrudgingly migrated from dep to go modules, and go modules are still working on their support for vendor directories:
https://github.com/golang/go/issues/27227
https://github.com/golang/go/issues/30240
This means there is a little overhead in working with the code base. If you need to make changes to dependent packages, you have two approaches you can take:
# 1)
a) Set `export GOFLAGS=-mod=vendor` in your ~/.bashrc file
b) use `go run main.go` to run lazydocker
c) if you need to bump a dependency e.g. jesseduffield/gocui, use
```
GOFLAGS= go get -u github.com/jesseduffield/gocui@master
go mod tidy
go mod vendor
```
# 2)
a) don't worry about your ~/.bashrc file
b) use `go run -mod=vendor main.go` to run lazydocker
c) if you need to bump a dependency e.g. jesseduffield/gocui, use
```
go get -u github.com/jesseduffield/gocui@master
go mod tidy
go mod vendor
```
Hopefully this will be much more streamlined in the future :)
## Code of conduct
Please note by participating in this project, you agree to abide by the [code of conduct].

View file

@ -1,49 +1,15 @@
ARG BASE_IMAGE_BUILDER=golang
ARG ALPINE_VERSION=3.20
ARG GO_VERSION=1.23
# run with:
# docker build -t lazydocker .
# docker run -it lazydocker:latest /bin/sh -l
FROM ${BASE_IMAGE_BUILDER}:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder
ARG GOARCH=amd64
ARG GOARM
ARG VERSION
ARG VCS_REF
WORKDIR /tmp/gobuild
FROM golang:alpine
WORKDIR /go/src/github.com/jesseduffield/lazydocker/
COPY ./ .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} GOARM=${GOARM} \
go build -a -mod=vendor \
-ldflags="-s -w \
-X main.commit=${VCS_REF} \
-X main.version=${VERSION} \
-X main.buildSource=Docker"
RUN CGO_ENABLED=0 GOOS=linux go build
FROM ${BASE_IMAGE_BUILDER}:${GO_VERSION}-alpine${ALPINE_VERSION} AS docker-builder
ARG GOARCH=amd64
ARG GOARM
ARG DOCKER_VERSION=v27.0.3
RUN apk add -U -q --progress --no-cache git bash coreutils gcc musl-dev
WORKDIR /go/src/github.com/docker/cli
RUN git clone --branch ${DOCKER_VERSION} --single-branch --depth 1 https://github.com/docker/cli.git . > /dev/null 2>&1
ENV CGO_ENABLED=0 \
GOARCH=${GOARCH} \
GOARM=${GOARM} \
DISABLE_WARN_OUTSIDE_CONTAINER=1
RUN ./scripts/build/binary
RUN rm build/docker && mv build/docker-linux-* build/docker
FROM scratch
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION
LABEL \
org.opencontainers.image.authors="jessedduffield@gmail.com" \
org.opencontainers.image.created=$BUILD_DATE \
org.opencontainers.image.version=$VERSION \
org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.url="https://github.com/jesseduffield/lazydocker" \
org.opencontainers.image.documentation="https://github.com/jesseduffield/lazydocker" \
org.opencontainers.image.source="https://github.com/jesseduffield/lazydocker" \
org.opencontainers.image.title="lazydocker" \
org.opencontainers.image.description="The lazier way to manage everything docker"
ENTRYPOINT [ "/bin/lazydocker" ]
COPY --from=docker-builder /go/src/github.com/docker/cli/build/docker /bin/docker
COPY --from=builder /tmp/gobuild/lazydocker /bin/lazydocker
FROM alpine:latest
RUN apk add -U git xdg-utils
WORKDIR /go/src/github.com/jesseduffield/lazydocker/
COPY --from=0 /go/src/github.com/jesseduffield/lazydocker /go/src/github.com/jesseduffield/lazydocker
COPY --from=0 /go/src/github.com/jesseduffield/lazydocker/lazydocker /bin/
RUN echo "alias gg=lazydocker" >> ~/.profile

345
Gopkg.lock generated Normal file
View file

@ -0,0 +1,345 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
digest = "1:2be791e7b333ff7c06f8fb3dc18a7d70580e9399dbdffd352621d067ff260b6e"
name = "github.com/Microsoft/go-winio"
packages = ["."]
pruneopts = "NUT"
revision = "1a8911d1ed007260465c3bfbbc785ac6915a0bb8"
version = "v0.4.12"
[[projects]]
branch = "master"
digest = "1:fb0dc5cd072a4871a6be75dbe13925330d13a77f58e4fcdc50b7d60b89f57aa1"
name = "github.com/acarl005/stripansi"
packages = ["."]
pruneopts = "NUT"
revision = "5a71ef0e047df0427e87a79f27009029921f1f9b"
[[projects]]
branch = "master"
digest = "1:cd7ba2b29e93e2a8384e813dfc80ebb0f85d9214762e6ca89bb55a58092eab87"
name = "github.com/cloudfoundry/jibber_jabber"
packages = ["."]
pruneopts = "NUT"
revision = "bcc4c8345a21301bf47c032ff42dd1aae2fe3027"
[[projects]]
digest = "1:a2c1d0e43bd3baaa071d1b9ed72c27d78169b2b269f71c105ac4ba34b1be4a39"
name = "github.com/davecgh/go-spew"
packages = ["spew"]
pruneopts = "NUT"
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
digest = "1:4ddc17aeaa82cb18c5f0a25d7c253a10682f518f4b2558a82869506eec223d76"
name = "github.com/docker/distribution"
packages = [
"digestset",
"reference",
]
pruneopts = "NUT"
revision = "2461543d988979529609e8cb6fca9ca190dc48da"
version = "v2.7.1"
[[projects]]
digest = "1:b68599a3121a71063743f22a860c840e13eb3ba7a8dffdb57edb0507b19f5fbe"
name = "github.com/docker/docker"
packages = [
"api/types",
"api/types/blkiodev",
"api/types/container",
"api/types/events",
"api/types/filters",
"api/types/mount",
"api/types/network",
"api/types/reference",
"api/types/registry",
"api/types/strslice",
"api/types/swarm",
"api/types/time",
"api/types/versions",
"api/types/volume",
"client",
"pkg/tlsconfig",
]
pruneopts = "NUT"
revision = "092cba3727bb9b4a2f0e922cd6c0f93ea270e363"
version = "v1.13.1"
[[projects]]
digest = "1:2a47f7eb1a2c30428d1ee6808cb66d4deb17e68a3e55d696f03c8068552ba5e8"
name = "github.com/docker/go-connections"
packages = [
"nat",
"sockets",
"tlsconfig",
]
pruneopts = "NUT"
revision = "7395e3f8aa162843a74ed6d48e79627d9792ac55"
version = "v0.4.0"
[[projects]]
digest = "1:97176fe8a268479a527d08df458c269dc27abf59c1643807d4e36398cbd9ef2d"
name = "github.com/docker/go-units"
packages = ["."]
pruneopts = "NUT"
revision = "519db1ee28dcc9fd2474ae59fca29a810482bfb1"
version = "v0.4.0"
[[projects]]
digest = "1:ade392a843b2035effb4b4a2efa2c3bab3eb29b992e98bacf9c898b0ecb54e45"
name = "github.com/fatih/color"
packages = ["."]
pruneopts = "NUT"
revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4"
version = "v1.7.0"
[[projects]]
digest = "1:ea1d5bfdb4ec5c2ee48c97865e6de1a28fa8c4849a3f56b27d521aa619038e06"
name = "github.com/go-errors/errors"
packages = ["."]
pruneopts = "NUT"
revision = "a6af135bd4e28680facf08a3d206b454abc877a4"
version = "v1.0.1"
[[projects]]
branch = "master"
digest = "1:d457d39e88f678ed14ac29517c3d74927a48dbc6a9f073fa241cf364a68cbe5c"
name = "github.com/heroku/rollrus"
packages = ["."]
pruneopts = "NUT"
revision = "fc0cef2ff331aebb24cd4e9ded7e20650f3d7006"
[[projects]]
digest = "1:aaa38889f11896ee3644d77e17dc7764cc47f5f3d3b488268df2af2b52541c5f"
name = "github.com/imdario/mergo"
packages = ["."]
pruneopts = "NUT"
revision = "7c29201646fa3de8506f701213473dd407f19646"
version = "v0.3.7"
[[projects]]
branch = "master"
digest = "1:7b2ab5c6de135d57d3a594b53068e2712da4d376b2f78097dec7b46d18deaa78"
name = "github.com/jesseduffield/asciigraph"
packages = ["."]
pruneopts = "NUT"
revision = "6d88e39309eef29cc5c2ab279ec600465827cbac"
[[projects]]
branch = "master"
digest = "1:48d9273fdc90d71d6de12efb6bea48333cd569f8ce8363ceb269d3fda4f6442b"
name = "github.com/jesseduffield/gocui"
packages = ["."]
pruneopts = "NUT"
revision = "619845fffd4d94c2e248f04bcdeaa2669ae24698"
[[projects]]
branch = "master"
digest = "1:a46c2f4863e5284ddb255c28750298e04bc8c0fc896bed6056e947673168b7be"
name = "github.com/jesseduffield/pty"
packages = ["."]
pruneopts = "NUT"
revision = "02db52c7e406c7abec44c717a173c7715e4c1b62"
[[projects]]
branch = "master"
digest = "1:3ab130f65766f5b7cc944d557df31c6a007ec017151705ec1e1b8719f2689021"
name = "github.com/jesseduffield/termbox-go"
packages = ["."]
pruneopts = "NUT"
revision = "1e272ff78dcb4c448870f464fda1cdcf2bf0b3dd"
[[projects]]
digest = "1:08c231ec84231a7e23d67e4b58f975e1423695a32467a362ee55a803f9de8061"
name = "github.com/mattn/go-colorable"
packages = ["."]
pruneopts = "NUT"
revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072"
version = "v0.0.9"
[[projects]]
digest = "1:bc4f7eec3b7be8c6cb1f0af6c1e3333d5bb71072951aaaae2f05067b0803f287"
name = "github.com/mattn/go-isatty"
packages = ["."]
pruneopts = "NUT"
revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39"
version = "v0.0.3"
[[projects]]
digest = "1:cb591533458f6eb6e2c1065ff3eac6b50263d7847deb23fc9f79b25bc608970e"
name = "github.com/mattn/go-runewidth"
packages = ["."]
pruneopts = "NUT"
revision = "9e777a8366cce605130a531d2cd6363d07ad7317"
version = "v0.0.2"
[[projects]]
branch = "master"
digest = "1:e1ca43ca8774cfff2a43265116b8ccc8221fcb994843a1124071d65dab1507e9"
name = "github.com/mcuadros/go-lookup"
packages = ["."]
pruneopts = "NUT"
revision = "5650f26be7675b629fff8356a50d906fa03e9c8b"
[[projects]]
digest = "1:a25c9a6b41e100f4ce164db80260f2b687095ba9d8b46a1d6072d3686cc020db"
name = "github.com/mgutz/str"
packages = ["."]
pruneopts = "NUT"
revision = "968bf66e3da857419e4f6e71b2d5c9ae95682dc4"
version = "v1.2.0"
[[projects]]
digest = "1:e0cc8395ea893c898ff5eb0850f4d9851c1f57c78c232304a026379a47a552d0"
name = "github.com/opencontainers/go-digest"
packages = ["."]
pruneopts = "NUT"
revision = "279bed98673dd5bef374d3b6e4b09e2af76183bf"
version = "v1.0.0-rc1"
[[projects]]
digest = "1:5cf3f025cbee5951a4ee961de067c8a89fc95a5adabead774f82822efabab121"
name = "github.com/pkg/errors"
packages = ["."]
pruneopts = "NUT"
revision = "645ef00459ed84a119197bfb8d8205042c6df63d"
version = "v0.8.0"
[[projects]]
digest = "1:0028cb19b2e4c3112225cd871870f2d9cf49b9b4276531f03438a88e94be86fe"
name = "github.com/pmezard/go-difflib"
packages = ["difflib"]
pruneopts = "NUT"
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0"
[[projects]]
digest = "1:41618aee8828e62dfe62d44f579c06892d0e98907d1c6d5bcd83bfe8536ec5a3"
name = "github.com/shibukawa/configdir"
packages = ["."]
pruneopts = "NUT"
revision = "e180dbdc8da04c4fa04272e875ce64949f38bd3e"
[[projects]]
digest = "1:b2339e83ce9b5c4f79405f949429a7f68a9a904fed903c672aac1e7ceb7f5f02"
name = "github.com/sirupsen/logrus"
packages = ["."]
pruneopts = "NUT"
revision = "3e01752db0189b9157070a0e1668a620f9a85da2"
version = "v1.0.6"
[[projects]]
branch = "master"
digest = "1:0e9a5ac14bcc11f205031a671b28c7e05cb88b2ebbe06f383c1ab0b2c12c7cb5"
name = "github.com/spkg/bom"
packages = ["."]
pruneopts = "NUT"
revision = "59b7046e48ad6bac800c5e1dd5142282cbfcf154"
[[projects]]
digest = "1:bacb8b590716ab7c33f2277240972c9582d389593ee8d66fc10074e0508b8126"
name = "github.com/stretchr/testify"
packages = ["assert"]
pruneopts = "NUT"
revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686"
version = "v1.2.2"
[[projects]]
branch = "master"
digest = "1:e42372d3f4921ec35df07f9b23239631e9d28580f7c1edcca212bc6daddc68fe"
name = "github.com/stvp/roll"
packages = ["."]
pruneopts = "NUT"
revision = "3627a5cbeaeaa68023abd02bb8687925265f2f63"
[[projects]]
digest = "1:cd5ffc5bda4e0296ab3e4de90dbb415259c78e45e7fab13694b14cde8ab74541"
name = "github.com/tcnksm/go-gitconfig"
packages = ["."]
pruneopts = "NUT"
revision = "d154598bacbf4501c095a309753c5d4af66caa81"
version = "v0.1.2"
[[projects]]
branch = "master"
digest = "1:3f3a05ae0b95893d90b9b3b5afdb79a9b3d96e4e36e099d841ae602e4aca0da8"
name = "golang.org/x/crypto"
packages = ["ssh/terminal"]
pruneopts = "NUT"
revision = "de0752318171da717af4ce24d0a2e8626afaeb11"
[[projects]]
branch = "master"
digest = "1:58cb5fbdf85e1f468c749aa31773ff102866831106f52762b2823ae235ca11e7"
name = "golang.org/x/net"
packages = [
"context",
"context/ctxhttp",
"internal/socks",
"proxy",
]
pruneopts = "NUT"
revision = "c39426892332e1bb5ec0a434a079bf82f5d30c54"
[[projects]]
branch = "master"
digest = "1:ec76a40fbfda0c329ee58f4e3b14b4279a939efce89eca020e934e2e5234eddd"
name = "golang.org/x/sys"
packages = [
"unix",
"windows",
]
pruneopts = "NUT"
revision = "98c5dad5d1a0e8a73845ecc8897d0bd56586511d"
[[projects]]
branch = "master"
digest = "1:16abb29c003995f7c4fdec9c19cca4f9867e271d3c6b93ed85f9cce257f7bce7"
name = "golang.org/x/xerrors"
packages = [
".",
"internal",
]
pruneopts = "NUT"
revision = "3ee3066db522c6628d440a3a91c4abdd7f5ef22f"
[[projects]]
digest = "1:7c95b35057a0ff2e19f707173cc1a947fa43a6eb5c4d300d196ece0334046082"
name = "gopkg.in/yaml.v2"
packages = ["."]
pruneopts = "NUT"
revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183"
version = "v2.2.1"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
input-imports = [
"github.com/acarl005/stripansi",
"github.com/cloudfoundry/jibber_jabber",
"github.com/docker/docker/api/types",
"github.com/docker/docker/api/types/filters",
"github.com/docker/docker/client",
"github.com/fatih/color",
"github.com/go-errors/errors",
"github.com/heroku/rollrus",
"github.com/imdario/mergo",
"github.com/jesseduffield/asciigraph",
"github.com/jesseduffield/gocui",
"github.com/jesseduffield/pty",
"github.com/mcuadros/go-lookup",
"github.com/mgutz/str",
"github.com/shibukawa/configdir",
"github.com/sirupsen/logrus",
"github.com/spkg/bom",
"github.com/stretchr/testify/assert",
"github.com/tcnksm/go-gitconfig",
"golang.org/x/xerrors",
"gopkg.in/yaml.v2",
]
solver-name = "gps-cdcl"
solver-version = 1

46
Gopkg.toml Normal file
View file

@ -0,0 +1,46 @@
# Gopkg.toml example
#
# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
#
[prune]
go-tests = true
unused-packages = true
non-go = true
[[constraint]]
name = "github.com/fatih/color"
version = "1.7.0"
[[constraint]]
branch = "master"
name = "github.com/jesseduffield/gocui"
[[constraint]]
branch = "master"
name = "github.com/jesseduffield/pty"
[[constraint]]
branch = "master"
name = "github.com/spkg/bom"
[[constraint]]
branch = "master"
name = "github.com/jesseduffield/asciigraph"

327
README.md

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

View file

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
services:
lazydocker:
build:
context: https://github.com/jesseduffield/lazydocker.git
args:
BASE_IMAGE_BUILDER: golang
GOARCH: amd64
GOARM:
image: lazyteam/lazydocker
container_name: lazydocker
stdin_open: true
tty: true
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/.config/jesseduffield/lazydocker

View file

@ -1,101 +1,26 @@
# User Config:
## Opening The User Config
The location of the user config will differ depending on your OS. You can open it via lazydocker by opening the application, clicking on the 'project' panel at the top left and pressing 'o' (or pressing 'e' if your files open in vim).
Changes to the user config will only take place after closing and re-opening lazydocker
### Locations:
- OSX: `~/Library/Application Support/jesseduffield/lazydocker/config.yml`
- Linux: `~/.config/lazydocker/config.yml`
- Windows: `C:\Users\<User>\AppData\Roaming\lazydocker\config.yml`
JSON schema is available for `config.yml` so that IntelliSense in Visual Studio Code
(completion and error checking) is automatically enabled when the [YAML Red Hat][yaml]
extension is installed. However, note that automatic schema detection only works
if your config file is in one of the standard paths mentioned above. If you
override the path to the file, you can still make IntelliSense work by adding
```yaml
# yaml-language-server: $schema=https://json.schemastore.org/lazydocker.json
```
to the top of your config file or via [Visual Studio Code settings.json config][settings].
[yaml]: https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml
[settings]: https://github.com/redhat-developer/vscode-yaml#associating-a-schema-to-a-glob-pattern-via-yamlschemas
## Default:
```yml
gui:
scrollHeight: 2
language: "auto" # one of 'auto' | 'en' | 'pl' | 'nl' | 'de' | 'tr'
border: "rounded" # one of 'rounded' | 'single' | 'double' | 'hidden'
theme:
activeBorderColor:
- green
- bold
inactiveBorderColor:
- white
selectedLineBgColor:
- blue
optionsTextColor:
- blue
returnImmediately: false
wrapMainPanel: true
# Side panel width as a ratio of the screen's width
sidePanelWidth: 0.333
# Determines whether we show the bottom line (the one containing keybinding
# info and the status of the app).
showBottomLine: true
# When true, increases vertical space used by focused side panel,
# creating an accordion effect
expandFocusedSidePanel: false
# Determines which screen mode will be used on startup
screenMode: "normal" # one of 'normal' | 'half' | 'fullscreen'
# Determines the style of the container status and container health display in the
# containers panel. "long": full words (default), "short": one or two characters,
# "icon": unicode emoji.
containerStatusHealthStyle: "long"
logs:
timestamps: false
since: '60m' # set to '' to show all logs
tail: '' # set to 200 to show last 200 lines of logs
commandTemplates:
dockerCompose: docker compose # Determines the Docker Compose command to run, referred to as .DockerCompose in commandTemplates
restartService: '{{ .DockerCompose }} restart {{ .Service.Name }}'
up: '{{ .DockerCompose }} up -d'
down: '{{ .DockerCompose }} down'
downWithVolumes: '{{ .DockerCompose }} down --volumes'
upService: '{{ .DockerCompose }} up -d {{ .Service.Name }}'
startService: '{{ .DockerCompose }} start {{ .Service.Name }}'
stopService: '{{ .DockerCompose }} stop {{ .Service.Name }}'
serviceLogs: '{{ .DockerCompose }} logs --since=60m --follow {{ .Service.Name }}'
viewServiceLogs: '{{ .DockerCompose }} logs --follow {{ .Service.Name }}'
rebuildService: '{{ .DockerCompose }} up -d --build {{ .Service.Name }}'
recreateService: '{{ .DockerCompose }} up -d --force-recreate {{ .Service.Name }}'
allLogs: '{{ .DockerCompose }} logs --tail=300 --follow'
viewAlLogs: '{{ .DockerCompose }} logs'
dockerComposeConfig: '{{ .DockerCompose }} config'
checkDockerComposeConfig: '{{ .DockerCompose }} config --quiet'
serviceTop: '{{ .DockerCompose }} top {{ .Service.Name }}'
oS:
openCommand: open {{filename}}
openLinkCommand: open {{link}}
stats:
graphs:
- caption: CPU (%)
statPath: DerivedStats.CPUPercentage
color: blue
- caption: Memory (%)
statPath: DerivedStats.MemoryPercentage
color: green
```
## To see what all of the config options mean, and what other options you can set, see [here](https://godoc.org/github.com/jesseduffield/lazydocker/pkg/config)
gui:
# stuff relating to the UI
scrollHeight: 2 # how many lines you scroll by
scrollPastBottom: true # enable scrolling past the bottom
theme:
activeBorderColor:
- white
- bold
inactiveBorderColor:
- white
optionsTextColor:
- blue
update:
method: prompt # can be: prompt | background | never
days: 14 # how often an update is checked for
reporting: 'undetermined' # one of: 'on' | 'off' | 'undetermined'
confirmOnQuit: false
```
## Color Attributes:
@ -114,32 +39,3 @@ The available attributes are:
- bold
- reverse # useful for high-contrast
- underline
## Custom Commands
You can add custom commands like so:
```yaml
customCommands:
containers:
- name: bash
attach: true
command: 'docker exec -it {{ .Container.ID }} bash'
serviceNames: []
```
You may use the following go templates (such as `{{ .Container.ID }}` above) in your commands:
- `{{ .DockerCompose }}`: the docker compose command (default: `docker-compose`)
- [`{{ .Container }}`](https://pkg.go.dev/github.com/jesseduffield/lazydocker@v0.20.0/pkg/commands#Container) and its fields. For example: `{{ .Container.Container.ImageID }}`
- [`{{ .Service }}`](https://pkg.go.dev/github.com/jesseduffield/lazydocker@v0.20.0/pkg/commands#Service) and its fields. For example: `{{ .Service.Name }}`
## Replacements
You can add replacements like so:
```yaml
replacements:
imageNamePrefixes:
'123456789012.dkr.ecr.us-east-1.amazonaws.com': '<prod>'
'923456789999.dkr.ecr.us-east-1.amazonaws.com': '<dev>'
```

View file

@ -1,114 +0,0 @@
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
# Lazydocker menü
## Projekt
<pre>
<kbd>e</kbd>: bearbeite lazydocker Konfiguration
<kbd>o</kbd>: öffne lazydocker Konfiguration
<kbd>m</kbd>: zeige Protokolle
<kbd>enter</kbd>: fokussieren aufs Hauptpanel
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<kbd>/</kbd>: filter list
</pre>
## Container
<pre>
<kbd>d</kbd>: entfernen
<kbd>e</kbd>: hide/show stopped containers
<kbd>p</kbd>: pause
<kbd>s</kbd>: anhalten
<kbd>r</kbd>: neustarten
<kbd>a</kbd>: anbinden
<kbd>m</kbd>: zeige Protokolle
<kbd>E</kbd>: exec shell
<kbd>c</kbd>: führe vordefinierten benutzerdefinierten Befehl aus
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: fokussieren aufs Hauptpanel
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<kbd>/</kbd>: filter list
</pre>
## Dienste
<pre>
<kbd>u</kbd>: up service
<kbd>d</kbd>: entferne Container
<kbd>s</kbd>: anhalten
<kbd>p</kbd>: pause
<kbd>r</kbd>: neustarten
<kbd>S</kbd>: start
<kbd>a</kbd>: anbinden
<kbd>m</kbd>: zeige Protokolle
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>R</kbd>: zeige Neustartoptionen
<kbd>c</kbd>: führe vordefinierten benutzerdefinierten Befehl aus
<kbd>b</kbd>: view bulk commands
<kbd>E</kbd>: exec shell
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: fokussieren aufs Hauptpanel
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<kbd>/</kbd>: filter list
</pre>
## Images
<pre>
<kbd>c</kbd>: führe vordefinierten benutzerdefinierten Befehl aus
<kbd>d</kbd>: entferne Image
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: fokussieren aufs Hauptpanel
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<kbd>/</kbd>: filter list
</pre>
## Volumes
<pre>
<kbd>c</kbd>: führe vordefinierten benutzerdefinierten Befehl aus
<kbd>d</kbd>: entferne Volume
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: fokussieren aufs Hauptpanel
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<kbd>/</kbd>: filter list
</pre>
## Netzwerk
<pre>
<kbd>c</kbd>: führe vordefinierten benutzerdefinierten Befehl aus
<kbd>d</kbd>: entferne Netzwerk
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: fokussieren aufs Hauptpanel
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<kbd>/</kbd>: filter list
</pre>
## Haupt
<pre>
<kbd>esc</kbd>: zurück
</pre>
## Global
<pre>
<kbd>+</kbd>: next screen mode (normal/half/fullscreen)
<kbd>_</kbd>: prev screen mode
<kbd>1</kbd>: focus projects panel
<kbd>2</kbd>: focus services panel
<kbd>3</kbd>: focus containers panel
<kbd>4</kbd>: focus images panel
<kbd>5</kbd>: focus volumes panel
<kbd>6</kbd>: focus networks panel
</pre>

View file

@ -1,114 +0,0 @@
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
# Lazydocker menu
## Project
<pre>
<kbd>e</kbd>: edit lazydocker config
<kbd>o</kbd>: open lazydocker config
<kbd>m</kbd>: view logs
<kbd>enter</kbd>: focus main panel
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<kbd>/</kbd>: filter list
</pre>
## Containers
<pre>
<kbd>d</kbd>: remove
<kbd>e</kbd>: hide/show stopped containers
<kbd>p</kbd>: pause
<kbd>s</kbd>: stop
<kbd>r</kbd>: restart
<kbd>a</kbd>: attach
<kbd>m</kbd>: view logs
<kbd>E</kbd>: exec shell
<kbd>c</kbd>: run predefined custom command
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: focus main panel
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<kbd>/</kbd>: filter list
</pre>
## Services
<pre>
<kbd>u</kbd>: up service
<kbd>d</kbd>: remove containers
<kbd>s</kbd>: stop
<kbd>p</kbd>: pause
<kbd>r</kbd>: restart
<kbd>S</kbd>: start
<kbd>a</kbd>: attach
<kbd>m</kbd>: view logs
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>R</kbd>: view restart options
<kbd>c</kbd>: run predefined custom command
<kbd>b</kbd>: view bulk commands
<kbd>E</kbd>: exec shell
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: focus main panel
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<kbd>/</kbd>: filter list
</pre>
## Images
<pre>
<kbd>c</kbd>: run predefined custom command
<kbd>d</kbd>: remove image
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: focus main panel
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<kbd>/</kbd>: filter list
</pre>
## Volumes
<pre>
<kbd>c</kbd>: run predefined custom command
<kbd>d</kbd>: remove volume
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: focus main panel
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<kbd>/</kbd>: filter list
</pre>
## Networks
<pre>
<kbd>c</kbd>: run predefined custom command
<kbd>d</kbd>: remove network
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: focus main panel
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<kbd>/</kbd>: filter list
</pre>
## Main
<pre>
<kbd>esc</kbd>: return
</pre>
## Global
<pre>
<kbd>+</kbd>: next screen mode (normal/half/fullscreen)
<kbd>_</kbd>: prev screen mode
<kbd>1</kbd>: focus projects panel
<kbd>2</kbd>: focus services panel
<kbd>3</kbd>: focus containers panel
<kbd>4</kbd>: focus images panel
<kbd>5</kbd>: focus volumes panel
<kbd>6</kbd>: focus networks panel
</pre>

View file

@ -1,114 +0,0 @@
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
# Lazydocker menú
## Proyecto
<pre>
<kbd>e</kbd>: editar configuración de lazydocker
<kbd>o</kbd>: abrir configuración de lazydocker
<kbd>m</kbd>: ver logs
<kbd>enter</kbd>: enfocar panel principal
<kbd>[</kbd>: anterior pestaña
<kbd>]</kbd>: siguiente pestaña
<kbd>/</kbd>: filtrar lista
</pre>
## Contenedores
<pre>
<kbd>d</kbd>: borrar
<kbd>e</kbd>: esconder/mostrar contenedores parados
<kbd>p</kbd>: pausa
<kbd>s</kbd>: parar
<kbd>r</kbd>: reiniciar
<kbd>a</kbd>: attach
<kbd>m</kbd>: ver logs
<kbd>E</kbd>: ejecutar shell
<kbd>c</kbd>: ejecutar comando personalizado
<kbd>b</kbd>: ver comandos masivos
<kbd>w</kbd>: abrir en navegador (first port is http)
<kbd>enter</kbd>: enfocar panel principal
<kbd>[</kbd>: anterior pestaña
<kbd>]</kbd>: siguiente pestaña
<kbd>/</kbd>: filtrar lista
</pre>
## Servicios
<pre>
<kbd>u</kbd>: levantar servicio
<kbd>d</kbd>: borrar contenedores
<kbd>s</kbd>: parar
<kbd>p</kbd>: pausa
<kbd>r</kbd>: reiniciar
<kbd>S</kbd>: iniciar
<kbd>a</kbd>: attach
<kbd>m</kbd>: ver logs
<kbd>U</kbd>: levantar proyecto
<kbd>D</kbd>: dar de baja el proyecto
<kbd>R</kbd>: ver opciones de reinicio
<kbd>c</kbd>: ejecutar comando personalizado
<kbd>b</kbd>: ver comandos masivos
<kbd>E</kbd>: ejecutar shell
<kbd>w</kbd>: abrir en navegador (first port is http)
<kbd>enter</kbd>: enfocar panel principal
<kbd>[</kbd>: anterior pestaña
<kbd>]</kbd>: siguiente pestaña
<kbd>/</kbd>: filtrar lista
</pre>
## Imágenes
<pre>
<kbd>c</kbd>: ejecutar comando personalizado
<kbd>d</kbd>: limpiar imagen
<kbd>b</kbd>: ver comandos masivos
<kbd>enter</kbd>: enfocar panel principal
<kbd>[</kbd>: anterior pestaña
<kbd>]</kbd>: siguiente pestaña
<kbd>/</kbd>: filtrar lista
</pre>
## Volúmenes
<pre>
<kbd>c</kbd>: ejecutar comando personalizado
<kbd>d</kbd>: limpiar volúmen
<kbd>b</kbd>: ver comandos masivos
<kbd>enter</kbd>: enfocar panel principal
<kbd>[</kbd>: anterior pestaña
<kbd>]</kbd>: siguiente pestaña
<kbd>/</kbd>: filtrar lista
</pre>
## Redes
<pre>
<kbd>c</kbd>: ejecutar comando personalizado
<kbd>d</kbd>: limpiar red
<kbd>b</kbd>: ver comandos masivos
<kbd>enter</kbd>: enfocar panel principal
<kbd>[</kbd>: anterior pestaña
<kbd>]</kbd>: siguiente pestaña
<kbd>/</kbd>: filtrar lista
</pre>
## Inicio
<pre>
<kbd>esc</kbd>: regresar
</pre>
## Global
<pre>
<kbd>+</kbd>: next screen mode (normal/half/fullscreen)
<kbd>_</kbd>: prev screen mode
<kbd>1</kbd>: focus projects panel
<kbd>2</kbd>: focus services panel
<kbd>3</kbd>: focus containers panel
<kbd>4</kbd>: focus images panel
<kbd>5</kbd>: focus volumes panel
<kbd>6</kbd>: focus networks panel
</pre>

View file

@ -1,114 +0,0 @@
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
# Lazydocker menu
## Projet
<pre>
<kbd>e</kbd>: modifier la configuration lazydocker
<kbd>o</kbd>: ouvrir la configuration lazydocker
<kbd>m</kbd>: voir les enregistrements
<kbd>enter</kbd>: focus panneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Conteneurs
<pre>
<kbd>d</kbd>: supprimer
<kbd>e</kbd>: cacher/montrer les conteneurs arrêtés
<kbd>p</kbd>: pause
<kbd>s</kbd>: arrêter
<kbd>r</kbd>: redémarrer
<kbd>a</kbd>: attacher
<kbd>m</kbd>: voir les enregistrements
<kbd>E</kbd>: exécuter le shell
<kbd>c</kbd>: exécuter une commande prédéfinie
<kbd>b</kbd>: voir les commandes groupées
<kbd>w</kbd>: ouvrir dans le navigateur (le premier port est http)
<kbd>enter</kbd>: focus panneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Services
<pre>
<kbd>u</kbd>: up service
<kbd>d</kbd>: supprimer les conteneurs
<kbd>s</kbd>: arrêter
<kbd>p</kbd>: pause
<kbd>r</kbd>: redémarrer
<kbd>S</kbd>: démarrer
<kbd>a</kbd>: attacher
<kbd>m</kbd>: voir les enregistrements
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>R</kbd>: voir les options de redémarrage
<kbd>c</kbd>: exécuter une commande prédéfinie
<kbd>b</kbd>: voir les commandes groupées
<kbd>E</kbd>: exécuter le shell
<kbd>w</kbd>: ouvrir dans le navigateur (le premier port est http)
<kbd>enter</kbd>: focus panneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Images
<pre>
<kbd>c</kbd>: exécuter une commande prédéfinie
<kbd>d</kbd>: supprimer l'image
<kbd>b</kbd>: voir les commandes groupées
<kbd>enter</kbd>: focus panneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Volumes
<pre>
<kbd>c</kbd>: exécuter une commande prédéfinie
<kbd>d</kbd>: supprimer le volume
<kbd>b</kbd>: voir les commandes groupées
<kbd>enter</kbd>: focus panneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Réseaux
<pre>
<kbd>c</kbd>: exécuter une commande prédéfinie
<kbd>d</kbd>: supprimer le réseau
<kbd>b</kbd>: voir les commandes groupées
<kbd>enter</kbd>: focus panneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Principal
<pre>
<kbd>esc</kbd>: retour
</pre>
## Global
<pre>
<kbd>+</kbd>: next screen mode (normal/half/fullscreen)
<kbd>_</kbd>: prev screen mode
<kbd>1</kbd>: focus projects panel
<kbd>2</kbd>: focus services panel
<kbd>3</kbd>: focus containers panel
<kbd>4</kbd>: focus images panel
<kbd>5</kbd>: focus volumes panel
<kbd>6</kbd>: focus networks panel
</pre>

View file

@ -1,114 +0,0 @@
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
# Lazydocker menu
## Project
<pre>
<kbd>e</kbd>: verander de lazydocker configuratie
<kbd>o</kbd>: open de lazydocker configuratie
<kbd>m</kbd>: bekijk logs
<kbd>enter</kbd>: focus hoofdpaneel
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<kbd>/</kbd>: filter list
</pre>
## Containers
<pre>
<kbd>d</kbd>: verwijder
<kbd>e</kbd>: verberg gestopte containers
<kbd>p</kbd>: pause
<kbd>s</kbd>: stop
<kbd>r</kbd>: herstart
<kbd>a</kbd>: verbinden
<kbd>m</kbd>: bekijk logs
<kbd>E</kbd>: exec shell
<kbd>c</kbd>: draai een vooraf bedacht aangepaste opdracht
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: focus hoofdpaneel
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<kbd>/</kbd>: filter list
</pre>
## Diensten
<pre>
<kbd>u</kbd>: up service
<kbd>d</kbd>: verwijder containers
<kbd>s</kbd>: stop
<kbd>p</kbd>: pause
<kbd>r</kbd>: herstart
<kbd>S</kbd>: start
<kbd>a</kbd>: verbinden
<kbd>m</kbd>: bekijk logs
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>R</kbd>: bekijk herstart opties
<kbd>c</kbd>: draai een vooraf bedacht aangepaste opdracht
<kbd>b</kbd>: view bulk commands
<kbd>E</kbd>: exec shell
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: focus hoofdpaneel
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<kbd>/</kbd>: filter list
</pre>
## Images
<pre>
<kbd>c</kbd>: draai een vooraf bedacht aangepaste opdracht
<kbd>d</kbd>: verwijder image
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: focus hoofdpaneel
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<kbd>/</kbd>: filter list
</pre>
## Volumes
<pre>
<kbd>c</kbd>: draai een vooraf bedacht aangepaste opdracht
<kbd>d</kbd>: verwijder volume
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: focus hoofdpaneel
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<kbd>/</kbd>: filter list
</pre>
## Networks
<pre>
<kbd>c</kbd>: draai een vooraf bedacht aangepaste opdracht
<kbd>d</kbd>: verwijder network
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: focus hoofdpaneel
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<kbd>/</kbd>: filter list
</pre>
## Hoofd
<pre>
<kbd>esc</kbd>: terug
</pre>
## Globaal
<pre>
<kbd>+</kbd>: next screen mode (normal/half/fullscreen)
<kbd>_</kbd>: prev screen mode
<kbd>1</kbd>: focus projects panel
<kbd>2</kbd>: focus services panel
<kbd>3</kbd>: focus containers panel
<kbd>4</kbd>: focus images panel
<kbd>5</kbd>: focus volumes panel
<kbd>6</kbd>: focus networks panel
</pre>

View file

@ -1,114 +0,0 @@
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
# Lazydocker menu
## Projekt
<pre>
<kbd>e</kbd>: edytuj konfigurację
<kbd>o</kbd>: otwórz konfigurację
<kbd>m</kbd>: pokaż logi
<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
<pre>
<kbd>d</kbd>: usuń
<kbd>e</kbd>: hide/show stopped containers
<kbd>p</kbd>: pause
<kbd>s</kbd>: zatrzymaj
<kbd>r</kbd>: restartuj
<kbd>a</kbd>: przyczep
<kbd>m</kbd>: pokaż logi
<kbd>E</kbd>: exec shell
<kbd>c</kbd>: wykonaj predefiniowaną własną komende
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: skup na głównym panelu
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<kbd>/</kbd>: filter list
</pre>
## Serwisy
<pre>
<kbd>u</kbd>: up service
<kbd>d</kbd>: usuń kontenery
<kbd>s</kbd>: zatrzymaj
<kbd>p</kbd>: pause
<kbd>r</kbd>: restartuj
<kbd>S</kbd>: start
<kbd>a</kbd>: przyczep
<kbd>m</kbd>: pokaż logi
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>R</kbd>: pokaż opcje restartu
<kbd>c</kbd>: wykonaj predefiniowaną własną komende
<kbd>b</kbd>: view bulk commands
<kbd>E</kbd>: exec shell
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: skup na głównym panelu
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<kbd>/</kbd>: filter list
</pre>
## Obrazy
<pre>
<kbd>c</kbd>: wykonaj predefiniowaną własną komende
<kbd>d</kbd>: usuń obraz
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: skup na głównym panelu
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<kbd>/</kbd>: filter list
</pre>
## Wolumeny
<pre>
<kbd>c</kbd>: wykonaj predefiniowaną własną komende
<kbd>d</kbd>: usuń wolumen
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: skup na głównym panelu
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<kbd>/</kbd>: filter list
</pre>
## Sieci
<pre>
<kbd>c</kbd>: wykonaj predefiniowaną własną komende
<kbd>d</kbd>: usuń sieci
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: skup na głównym panelu
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<kbd>/</kbd>: filter list
</pre>
## Główne
<pre>
<kbd>esc</kbd>: powrót
</pre>
## Globalne
<pre>
<kbd>+</kbd>: next screen mode (normal/half/fullscreen)
<kbd>_</kbd>: prev screen mode
<kbd>1</kbd>: focus projects panel
<kbd>2</kbd>: focus services panel
<kbd>3</kbd>: focus containers panel
<kbd>4</kbd>: focus images panel
<kbd>5</kbd>: focus volumes panel
<kbd>6</kbd>: focus networks panel
</pre>

View file

@ -1,114 +0,0 @@
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
# Lazydocker menu
## Projeto
<pre>
<kbd>e</kbd>: editar configuração do lazydocker
<kbd>o</kbd>: abrir configuração do lazydocker
<kbd>m</kbd>: ver logs
<kbd>enter</kbd>: focar no painel principal
<kbd>[</kbd>: aba anterior
<kbd>]</kbd>: próxima aba
<kbd>/</kbd>: filtrar lista
</pre>
## Contêineres
<pre>
<kbd>d</kbd>: remover
<kbd>e</kbd>: ocultar/mostrar contêineres parados
<kbd>p</kbd>: pausar
<kbd>s</kbd>: parar
<kbd>r</kbd>: reiniciar
<kbd>a</kbd>: anexar
<kbd>m</kbd>: ver logs
<kbd>E</kbd>: executar shell
<kbd>c</kbd>: executar comando personalizado predefinido
<kbd>b</kbd>: ver comandos em massa
<kbd>w</kbd>: abrir no navegador (primeira porta é http)
<kbd>enter</kbd>: focar no painel principal
<kbd>[</kbd>: aba anterior
<kbd>]</kbd>: próxima aba
<kbd>/</kbd>: filtrar lista
</pre>
## Serviços
<pre>
<kbd>u</kbd>: subir serviço
<kbd>d</kbd>: remover contêineres
<kbd>s</kbd>: parar
<kbd>p</kbd>: pausar
<kbd>r</kbd>: reiniciar
<kbd>S</kbd>: iniciar
<kbd>a</kbd>: anexar
<kbd>m</kbd>: ver logs
<kbd>U</kbd>: subir projeto
<kbd>D</kbd>: derrubar projeto
<kbd>R</kbd>: ver opções de reinício
<kbd>c</kbd>: executar comando personalizado predefinido
<kbd>b</kbd>: ver comandos em massa
<kbd>E</kbd>: executar shell
<kbd>w</kbd>: abrir no navegador (primeira porta é http)
<kbd>enter</kbd>: focar no painel principal
<kbd>[</kbd>: aba anterior
<kbd>]</kbd>: próxima aba
<kbd>/</kbd>: filtrar lista
</pre>
## Imagens
<pre>
<kbd>c</kbd>: executar comando personalizado predefinido
<kbd>d</kbd>: remover imagem
<kbd>b</kbd>: ver comandos em massa
<kbd>enter</kbd>: focar no painel principal
<kbd>[</kbd>: aba anterior
<kbd>]</kbd>: próxima aba
<kbd>/</kbd>: filtrar lista
</pre>
## Volumes
<pre>
<kbd>c</kbd>: executar comando personalizado predefinido
<kbd>d</kbd>: remover volume
<kbd>b</kbd>: ver comandos em massa
<kbd>enter</kbd>: focar no painel principal
<kbd>[</kbd>: aba anterior
<kbd>]</kbd>: próxima aba
<kbd>/</kbd>: filtrar lista
</pre>
## Redes
<pre>
<kbd>c</kbd>: executar comando personalizado predefinido
<kbd>d</kbd>: remover rede
<kbd>b</kbd>: ver comandos em massa
<kbd>enter</kbd>: focar no painel principal
<kbd>[</kbd>: aba anterior
<kbd>]</kbd>: próxima aba
<kbd>/</kbd>: filtrar lista
</pre>
## Principal
<pre>
<kbd>esc</kbd>: retornar
</pre>
## Global
<pre>
<kbd>+</kbd>: modo de tela seguinte (normal/meia/tela cheia)
<kbd>_</kbd>: modo de tela anterior
<kbd>1</kbd>: focus projects panel
<kbd>2</kbd>: focus services panel
<kbd>3</kbd>: focus containers panel
<kbd>4</kbd>: focus images panel
<kbd>5</kbd>: focus volumes panel
<kbd>6</kbd>: focus networks panel
</pre>

View file

@ -1,114 +0,0 @@
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
# Lazydocker menü
## Proje
<pre>
<kbd>e</kbd>: lazzydocker ayarlarını düzenle
<kbd>o</kbd>: lazydocker ayarlarını
<kbd>m</kbd>: kayıt defterini görüntüle
<kbd>enter</kbd>: ana panele odaklan
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<kbd>/</kbd>: filter list
</pre>
## Konteynerler
<pre>
<kbd>d</kbd>: kaldır
<kbd>e</kbd>: hide/show stopped containers
<kbd>p</kbd>: pause
<kbd>s</kbd>: durdur
<kbd>r</kbd>: yeniden başlat
<kbd>a</kbd>: bağlan/iliştir
<kbd>m</kbd>: kayıt defterini görüntüle
<kbd>E</kbd>: exec shell
<kbd>c</kbd>: önceden tanımlanmış özel komutu çalıştır
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: ana panele odaklan
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<kbd>/</kbd>: filter list
</pre>
## Servisler
<pre>
<kbd>u</kbd>: up service
<kbd>d</kbd>: konteynerleri kaldır
<kbd>s</kbd>: durdur
<kbd>p</kbd>: pause
<kbd>r</kbd>: yeniden başlat
<kbd>S</kbd>: start
<kbd>a</kbd>: bağlan/iliştir
<kbd>m</kbd>: kayıt defterini görüntüle
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>R</kbd>: yeniden başlatma seçeneklerini görüntüle
<kbd>c</kbd>: önceden tanımlanmış özel komutu çalıştır
<kbd>b</kbd>: view bulk commands
<kbd>E</kbd>: exec shell
<kbd>w</kbd>: open in browser (first port is http)
<kbd>enter</kbd>: ana panele odaklan
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<kbd>/</kbd>: filter list
</pre>
## Imajlar
<pre>
<kbd>c</kbd>: önceden tanımlanmış özel komutu çalıştır
<kbd>d</kbd>: imajı kaldır
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: ana panele odaklan
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<kbd>/</kbd>: filter list
</pre>
## Alanlar
<pre>
<kbd>c</kbd>: önceden tanımlanmış özel komutu çalıştır
<kbd>d</kbd>: alanı kaldır
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: ana panele odaklan
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<kbd>/</kbd>: filter list
</pre>
## Ağları
<pre>
<kbd>c</kbd>: önceden tanımlanmış özel komutu çalıştır
<kbd>d</kbd>: ağı kaldır
<kbd>b</kbd>: view bulk commands
<kbd>enter</kbd>: ana panele odaklan
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<kbd>/</kbd>: filter list
</pre>
## Ana
<pre>
<kbd>esc</kbd>: dönüş
</pre>
## Global
<pre>
<kbd>+</kbd>: next screen mode (normal/half/fullscreen)
<kbd>_</kbd>: prev screen mode
<kbd>1</kbd>: focus projects panel
<kbd>2</kbd>: focus services panel
<kbd>3</kbd>: focus containers panel
<kbd>4</kbd>: focus images panel
<kbd>5</kbd>: focus volumes panel
<kbd>6</kbd>: focus networks panel
</pre>

View file

@ -1,114 +0,0 @@
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
# Lazydocker 菜单
## 项目
<pre>
<kbd>e</kbd>: 编辑lazydocker配置
<kbd>o</kbd>: 打开lazydocker配置
<kbd>m</kbd>: 查看日志
<kbd>enter</kbd>: 聚焦主面板
<kbd>[</kbd>: 上一个选项卡
<kbd>]</kbd>: 下一个选项卡
<kbd>/</kbd>: 过滤列表
</pre>
## 容器
<pre>
<kbd>d</kbd>: 移除
<kbd>e</kbd>: 隐藏/显示已停止的容器
<kbd>p</kbd>: 暂停
<kbd>s</kbd>: 停止
<kbd>r</kbd>: 重新启动
<kbd>a</kbd>: attach
<kbd>m</kbd>: 查看日志
<kbd>E</kbd>: 执行shell
<kbd>c</kbd>: 运行预定义的自定义命令
<kbd>b</kbd>: 查看批量命令
<kbd>w</kbd>: 在浏览器中打开(第一个端口为http)
<kbd>enter</kbd>: 聚焦主面板
<kbd>[</kbd>: 上一个选项卡
<kbd>]</kbd>: 下一个选项卡
<kbd>/</kbd>: 过滤列表
</pre>
## 服务
<pre>
<kbd>u</kbd>: 启动服务
<kbd>d</kbd>: 移除容器
<kbd>s</kbd>: 停止
<kbd>p</kbd>: 暂停
<kbd>r</kbd>: 重新启动
<kbd>S</kbd>: 启动项目
<kbd>a</kbd>: attach
<kbd>m</kbd>: 查看日志
<kbd>U</kbd>: 创建并启动容器
<kbd>D</kbd>: 停止并移除容器
<kbd>R</kbd>: 查看重启选项
<kbd>c</kbd>: 运行预定义的自定义命令
<kbd>b</kbd>: 查看批量命令
<kbd>E</kbd>: 执行shell
<kbd>w</kbd>: 在浏览器中打开(第一个端口为http)
<kbd>enter</kbd>: 聚焦主面板
<kbd>[</kbd>: 上一个选项卡
<kbd>]</kbd>: 下一个选项卡
<kbd>/</kbd>: 过滤列表
</pre>
## 镜像
<pre>
<kbd>c</kbd>: 运行预定义的自定义命令
<kbd>d</kbd>: 移除镜像
<kbd>b</kbd>: 查看批量命令
<kbd>enter</kbd>: 聚焦主面板
<kbd>[</kbd>: 上一个选项卡
<kbd>]</kbd>: 下一个选项卡
<kbd>/</kbd>: 过滤列表
</pre>
## 卷
<pre>
<kbd>c</kbd>: 运行预定义的自定义命令
<kbd>d</kbd>: 移除卷
<kbd>b</kbd>: 查看批量命令
<kbd>enter</kbd>: 聚焦主面板
<kbd>[</kbd>: 上一个选项卡
<kbd>]</kbd>: 下一个选项卡
<kbd>/</kbd>: 过滤列表
</pre>
## 网络
<pre>
<kbd>c</kbd>: 运行预定义的自定义命令
<kbd>d</kbd>: 移除网络
<kbd>b</kbd>: 查看批量命令
<kbd>enter</kbd>: 聚焦主面板
<kbd>[</kbd>: 上一个选项卡
<kbd>]</kbd>: 下一个选项卡
<kbd>/</kbd>: 过滤列表
</pre>
## 主要
<pre>
<kbd>esc</kbd>: 返回
</pre>
## 全局
<pre>
<kbd>+</kbd>: 下一个屏幕模式(正常/半屏/全屏)
<kbd>_</kbd>: 上一个屏幕模式
<kbd>1</kbd>: focus projects panel
<kbd>2</kbd>: focus services panel
<kbd>3</kbd>: focus containers panel
<kbd>4</kbd>: focus images panel
<kbd>5</kbd>: focus volumes panel
<kbd>6</kbd>: focus networks panel
</pre>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

62
getter.rb Normal file
View file

@ -0,0 +1,62 @@
ids = %w[
AnonymousReportingPrompt
AnonymousReportingTitle
attach
cancel
close
Confirm
confirmPruneImages
ConfirmQuit
ContainersTitle
CustomCommand
Donate
EditConfig
Error
ErrorOccurred
execute
ImagesTitle
menu
mustForceToRemoveContainer
navigate
nextContext
NoContainers
NoImages
NotEnoughSpace
NoViewMachingNewLineFocusedSwitchStatement
OpenConfig
pressEnterToReturn
previousContext
pruneImages
PruningStatus
remove
removeImage
removeService
removeWithoutPrune
removeWithVolumes
RemovingStatus
resizingPopupPanel
restart
RestartingStatus
RunningSubprocess
scroll
ServicesTitle
StatusTitle
stop
StopContainer
StoppingStatus
StopService
viewLogs
]
f = File.read('pkg/i18n/english.go')
f.lines.each_with_index do |line, index|
if line[/ID:/]
stripped = line.strip.gsub(/ID: *"/, '",').gsub('",', '')
if ids.include?(stripped)
key = stripped[0].upcase + stripped[1..-1]
value = f.lines[index+1].strip.gsub(/Other: *"/, '').gsub('",', '')
puts "#{key}: \"#{value}\","
end
end
end

85
go.mod
View file

@ -1,85 +0,0 @@
module github.com/jesseduffield/lazydocker
go 1.22
toolchain go1.23.6
require (
github.com/OpenPeeDeeP/xdg v0.2.1-0.20190312153938-4ba9e1eb294c
github.com/boz/go-throttle v0.0.0-20160922054636-fdc4eab740c1
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/docker/cli v27.1.1+incompatible
github.com/docker/docker v28.5.2+incompatible
github.com/fatih/color v1.10.0
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.5.0
github.com/imdario/mergo v0.3.16
github.com/integrii/flaggy v1.4.0
github.com/jesseduffield/asciigraph v0.0.0-20190605104717-6d88e39309ee
github.com/jesseduffield/gocui v0.3.1-0.20240418080333-8cd33929c513
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10
github.com/jesseduffield/lazycore v0.0.0-20221023210126-718a4caea996
github.com/jesseduffield/yaml v0.0.0-20190702115811-b900b7e08b56
github.com/mattn/go-runewidth v0.0.15
github.com/mcuadros/go-lookup v0.0.0-20171110082742-5650f26be767
github.com/mgutz/str v1.2.0
github.com/pmezard/go-difflib v1.0.0
github.com/samber/lo v1.31.0
github.com/sasha-s/go-deadlock v0.3.1
github.com/sirupsen/logrus v1.9.3
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad
github.com/stretchr/testify v1.9.0
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
)
require (
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fvbommel/sortorder v1.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.7.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-yaml v1.11.0
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/onsi/ginkgo v1.8.0 // indirect
github.com/onsi/gomega v1.5.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
golang.org/x/crypto v0.24.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/term v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

247
go.sum
View file

@ -1,247 +0,0 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OpenPeeDeeP/xdg v0.2.1-0.20190312153938-4ba9e1eb294c h1:YDsGA6tou+tAxVe0Dre29iSbQ8TrWdWfwOisKArJT5E=
github.com/OpenPeeDeeP/xdg v0.2.1-0.20190312153938-4ba9e1eb294c/go.mod h1:tMoSueLQlMf0TCldjrJLNIjAc5qAOIcHt5REi88/Ygo=
github.com/boz/go-throttle v0.0.0-20160922054636-fdc4eab740c1 h1:1fx+RA5lk1ZkzPAUP7DEgZnVHYxEcHO77vQO/V8z/2Q=
github.com/boz/go-throttle v0.0.0-20160922054636-fdc4eab740c1/go.mod h1:z0nyIb42Zs97wyX1V+8MbEFhHeTw1OgFQfR6q57ZuHc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 h1:tuijfIjZyjZaHq9xDUh0tNitwXshJpbLkqMOJv4H3do=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE=
github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/goccy/go-yaml v1.11.0 h1:n7Z+zx8S9f9KgzG6KtQKf+kwqXZlLNR2F6018Dgau54=
github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.5.0 h1:1Opow3+BWDwqor78DcJkJCIwnkviFi+rrOANki9BUFw=
github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/integrii/flaggy v1.4.0 h1:A1x7SYx4jqu5NSrY14z8Z+0UyX2S5ygfJJrfolWR3zM=
github.com/integrii/flaggy v1.4.0/go.mod h1:tnTxHeTJbah0gQ6/K0RW0J7fMUBk9MCF5blhm43LNpI=
github.com/jesseduffield/asciigraph v0.0.0-20190605104717-6d88e39309ee h1:7Zi/OQlGbMz4MT2V1+prN/gv1C64NDyVb/MbJnS0ZfA=
github.com/jesseduffield/asciigraph v0.0.0-20190605104717-6d88e39309ee/go.mod h1:Z9UKHveKXXgyo8ME7R8yxh/BUTFOK+FgfWKlhy8oOAg=
github.com/jesseduffield/gocui v0.3.1-0.20240418080333-8cd33929c513 h1:Y1bw5iItrsDCumATc/rklIJ/6K+68ieiWZJedhrNuXo=
github.com/jesseduffield/gocui v0.3.1-0.20240418080333-8cd33929c513/go.mod h1:XtEbqCbn45keRXEu+OMZkjN5gw6AEob59afsgHjokZ8=
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 h1:jmpr7KpX2+2GRiE91zTgfq49QvgiqB0nbmlwZ8UnOx0=
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10/go.mod h1:aA97kHeNA+sj2Hbki0pvLslmE4CbDyhBeSSTUUnOuVo=
github.com/jesseduffield/lazycore v0.0.0-20221023210126-718a4caea996 h1:CH1en6GpXSwnXl5Ehc4WX1NpS3uw9qbi7o9A4T2YYmA=
github.com/jesseduffield/lazycore v0.0.0-20221023210126-718a4caea996/go.mod h1:qxN4mHOAyeIDLP7IK7defgPClM/z1Kze8VVQiaEjzsQ=
github.com/jesseduffield/yaml v0.0.0-20190702115811-b900b7e08b56 h1:33wSxJWU/f2TAozHYtJ8zqBxEnEVYM+22moLoiAkxvg=
github.com/jesseduffield/yaml v0.0.0-20190702115811-b900b7e08b56/go.mod h1:FZJBwOhE+RXz8EVZfY+xnbCw2cVOwxlK3/aIi581z/s=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mcuadros/go-lookup v0.0.0-20171110082742-5650f26be767 h1:BrhJNdEFWGuiJk/3/SwsG5Rex3zjFxYsDi2bpd7382Y=
github.com/mcuadros/go-lookup v0.0.0-20171110082742-5650f26be767/go.mod h1:ct+byCpkFokm4J0tiuAvB8cf2ttm6GcCe89Yr25nGKg=
github.com/mgutz/str v1.2.0 h1:4IzWSdIz9qPQWLfKZ0rJcV0jcUDpxvP4JVZ4GXQyvSw=
github.com/mgutz/str v1.2.0/go.mod h1:w1v0ofgLaJdoD0HpQ3fycxKD1WtxpjSo151pK/31q6w=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.31.0 h1:Sfa+/064Tdo4SvlohQUQzBhgSer9v/coGvKQI/XLWAM=
github.com/samber/lo v1.31.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8=
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad h1:fiWzISvDn0Csy5H0iwgAuJGQTUpVfEMJJd4nRFXogbc=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0=
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw=
google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=

View file

@ -1,6 +0,0 @@
#!/bin/bash
docker build --build-arg BUILD_DATE=`date -u +"%Y-%m-%dT%H:%M:%SZ"` \
--build-arg VCS_REF=`git rev-parse --short HEAD` \
--build-arg VERSION=`git describe --abbrev=0 --tag` \
-t $IMAGE_NAME .

97
main.go
View file

@ -1,95 +1,56 @@
package main
import (
"bytes"
"flag"
"fmt"
"log"
"os"
"runtime"
"runtime/debug"
"github.com/docker/docker/client"
"github.com/go-errors/errors"
"github.com/integrii/flaggy"
"github.com/jesseduffield/lazydocker/pkg/app"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/jesseduffield/yaml"
"github.com/samber/lo"
)
const DEFAULT_VERSION = "unversioned"
var (
commit string
version = DEFAULT_VERSION
version = "unversioned"
date string
buildSource = "unknown"
configFlag = false
debuggingFlag = false
composeFiles []string
projectName string
configFlag = flag.Bool("config", false, "Print the current default config")
debuggingFlag = flag.Bool("debug", false, "a boolean")
versionFlag = flag.Bool("v", false, "Print the current version")
)
func main() {
updateBuildInfo()
info := fmt.Sprintf(
"%s\nDate: %s\nBuildSource: %s\nCommit: %s\nOS: %s\nArch: %s",
version,
date,
buildSource,
commit,
runtime.GOOS,
runtime.GOARCH,
)
flaggy.SetName("lazydocker")
flaggy.SetDescription("The lazier way to manage everything docker")
flaggy.DefaultParser.AdditionalHelpPrepend = "https://github.com/jesseduffield/lazydocker"
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()
if configFlag {
var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)
err := encoder.Encode(config.GetDefaultConfig())
if err != nil {
log.Fatal(err.Error())
}
fmt.Printf("%v\n", buf.String())
flag.Parse()
if *versionFlag {
fmt.Printf("commit=%s, build date=%s, build source=%s, version=%s, os=%s, arch=%s\n", commit, date, buildSource, version, runtime.GOOS, runtime.GOARCH)
os.Exit(0)
}
projectDir, err := os.Getwd()
if err != nil {
log.Fatal(err.Error())
if *configFlag {
fmt.Printf("%s\n", config.GetDefaultConfig())
os.Exit(0)
}
appConfig, err := config.NewAppConfig("lazydocker", version, commit, date, buildSource, debuggingFlag, composeFiles, projectDir, projectName)
// for now we're always in debug mode so we're not passing *debuggingFlag
*debuggingFlag = true
appConfig, err := config.NewAppConfig("lazydocker", version, commit, date, buildSource, *debuggingFlag)
if err != nil {
log.Fatal(err.Error())
}
app, err := app.NewApp(appConfig)
if err == nil {
err = app.Run()
}
app.Close()
if err != nil {
if errMessage, known := app.KnownError(err); known {
log.Println(errMessage)
os.Exit(0)
}
if client.IsErrConnectionFailed(err) {
log.Println(app.Tr.ConnectionFailed)
os.Exit(0)
@ -99,30 +60,6 @@ func main() {
stackTrace := newErr.ErrorStack()
app.Log.Error(stackTrace)
log.Fatalf("%s\n\n%s", app.Tr.ErrorOccurred, stackTrace)
}
}
func updateBuildInfo() {
if version == DEFAULT_VERSION {
if buildInfo, ok := debug.ReadBuildInfo(); ok {
revision, ok := lo.Find(buildInfo.Settings, func(setting debug.BuildSetting) bool {
return setting.Key == "vcs.revision"
})
if ok {
commit = revision.Value
// if lazydocker was built from source we'll show the version as the
// abbreviated commit hash
version = utils.SafeTruncate(revision.Value, 7)
}
// if version hasn't been set we assume that neither has the date
time, ok := lo.Find(buildInfo.Settings, func(setting debug.BuildSetting) bool {
return setting.Key == "vcs.time"
})
if ok {
date = time.Value
}
}
log.Fatal(fmt.Sprintf("%s\n\n%s", app.Tr.ErrorOccurred, stackTrace))
}
}

View file

@ -2,14 +2,12 @@ package app
import (
"io"
"strings"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/gui"
"github.com/jesseduffield/lazydocker/pkg/i18n"
"github.com/jesseduffield/lazydocker/pkg/log"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/sirupsen/logrus"
)
@ -35,10 +33,7 @@ func NewApp(config *config.AppConfig) (*App, error) {
}
var err error
app.Log = log.NewLogger(config, "23432119147a4367abf7c0de2aa99a2d")
app.Tr, err = i18n.NewTranslationSetFromConfig(app.Log, config.UserConfig.Gui.Language)
if err != nil {
return app, err
}
app.Tr = i18n.NewTranslationSet(app.Log)
app.OSCommand = commands.NewOSCommand(app.Log, config)
// here is the place to make use of the docker-compose.yml file in the current directory
@ -47,7 +42,6 @@ func NewApp(config *config.AppConfig) (*App, error) {
if err != nil {
return app, err
}
app.closers = append(app.closers, app.DockerCommand)
app.Gui, err = gui.NewGui(app.Log, app.DockerCommand, app.OSCommand, app.Tr, config, app.ErrorChan)
if err != nil {
return app, err
@ -56,34 +50,16 @@ func NewApp(config *config.AppConfig) (*App, error) {
}
func (app *App) Run() error {
return app.Gui.Run()
return app.Gui.RunWithSubprocesses()
}
// Close closes any resources
func (app *App) Close() error {
return utils.CloseMany(app.closers)
}
type errorMapping struct {
originalError string
newError string
}
// KnownError takes an error and tells us whether it's an error that we know about where we can print a nicely formatted version of it rather than panicking with a stack trace
func (app *App) KnownError(err error) (string, bool) {
errorMessage := err.Error()
mappings := []errorMapping{
{
originalError: "Got permission denied while trying to connect to the Docker daemon socket",
newError: app.Tr.CannotAccessDockerSocketError,
},
}
for _, mapping := range mappings {
if strings.Contains(errorMessage, mapping.originalError) {
return mapping.newError, true
for _, closer := range app.closers {
err := closer.Close()
if err != nil {
return err
}
}
return "", false
return nil
}

View file

@ -1,87 +0,0 @@
package cheatsheet
import (
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"regexp"
"github.com/jesseduffield/lazycore/pkg/utils"
"github.com/pmezard/go-difflib/difflib"
)
func Check() {
dir := GetKeybindingsDir()
tmpDir := filepath.Join(os.TempDir(), "lazydocker_cheatsheet")
err := os.RemoveAll(tmpDir)
if err != nil {
log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
defer os.RemoveAll(tmpDir)
if err = os.Mkdir(tmpDir, 0o700); err != nil {
log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
generateAtDir(tmpDir)
actualContent := obtainContent(dir)
expectedContent := obtainContent(tmpDir)
if expectedContent == "" {
log.Fatal("empty expected content")
}
if actualContent != expectedContent {
if err := difflib.WriteUnifiedDiff(os.Stdout, difflib.UnifiedDiff{
A: difflib.SplitLines(expectedContent),
B: difflib.SplitLines(actualContent),
FromFile: "Expected",
FromDate: "",
ToFile: "Actual",
ToDate: "",
Context: 1,
}); err != nil {
log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
fmt.Printf(
"\nCheatsheets are out of date. Please run `%s` at the project root and commit the changes. "+
"If you run the script and no keybindings files are updated as a result, try rebasing onto master"+
"and trying again.\n",
generateCheatsheetCmd,
)
os.Exit(1)
}
fmt.Println("\nCheatsheets are up to date")
}
func GetKeybindingsDir() string {
return utils.GetLazyRootDirectory() + "/docs/keybindings"
}
func obtainContent(dir string) string {
re := regexp.MustCompile(`Keybindings_\w+\.md$`)
content := ""
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if re.MatchString(path) {
bytes, err := os.ReadFile(path)
if err != nil {
log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
content += fmt.Sprintf("\n%s\n\n", filepath.Base(path))
content += string(bytes)
}
return nil
})
if err != nil {
log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
return content
}

View file

@ -2,17 +2,18 @@ package commands
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/fatih/color"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazydocker/pkg/i18n"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
"golang.org/x/xerrors"
)
@ -21,28 +22,315 @@ import (
type Container struct {
Name string
ServiceName string
ServiceID string
ContainerNumber string // might make this an int in the future if need be
// OneOff tells us if the container is just a job container or is actually bound to the service
OneOff bool
ProjectName string
ID string
Container container.Summary
Container types.Container
DisplayString string
Client *client.Client
OSCommand *OSCommand
Config *config.AppConfig
Log *logrus.Entry
StatHistory []*RecordedStats
Details container.InspectResponse
CLIStats ContainerCliStat // for realtime we use the CLI, for long-term we use the client
StatHistory []RecordedStats
Details Details
MonitoringStats bool
DockerCommand LimitedDockerCommand
Tr *i18n.TranslationSet
}
StatsMutex deadlock.Mutex
// RecordedStats contains both the container stats we've received from docker, and our own derived stats from those container stats. When configuring a graph, you're basically specifying the path of a value in this struct
type RecordedStats struct {
ClientStats ContainerStats
DerivedStats DerivedStats
RecordedAt time.Time
}
// DerivedStats contains some useful stats that we've calculated based on the raw container stats that we got back from docker
type DerivedStats struct {
CPUPercentage float64
MemoryPercentage float64
}
// Details is a struct containing what we get back from `docker inspect` on a container
type Details struct {
ID string `json:"Id"`
Created time.Time `json:"Created"`
Path string `json:"Path"`
Args []string `json:"Args"`
State struct {
Status string `json:"Status"`
Running bool `json:"Running"`
Paused bool `json:"Paused"`
Restarting bool `json:"Restarting"`
OOMKilled bool `json:"OOMKilled"`
Dead bool `json:"Dead"`
Pid int `json:"Pid"`
ExitCode int `json:"ExitCode"`
Error string `json:"Error"`
StartedAt time.Time `json:"StartedAt"`
FinishedAt time.Time `json:"FinishedAt"`
} `json:"State"`
Image string `json:"Image"`
ResolvConfPath string `json:"ResolvConfPath"`
HostnamePath string `json:"HostnamePath"`
HostsPath string `json:"HostsPath"`
LogPath string `json:"LogPath"`
Name string `json:"Name"`
RestartCount int `json:"RestartCount"`
Driver string `json:"Driver"`
Platform string `json:"Platform"`
MountLabel string `json:"MountLabel"`
ProcessLabel string `json:"ProcessLabel"`
AppArmorProfile string `json:"AppArmorProfile"`
ExecIDs interface{} `json:"ExecIDs"`
HostConfig struct {
Binds []string `json:"Binds"`
ContainerIDFile string `json:"ContainerIDFile"`
LogConfig struct {
Type string `json:"Type"`
Config struct {
} `json:"Config"`
} `json:"LogConfig"`
NetworkMode string `json:"NetworkMode"`
PortBindings struct {
} `json:"PortBindings"`
RestartPolicy struct {
Name string `json:"Name"`
MaximumRetryCount int `json:"MaximumRetryCount"`
} `json:"RestartPolicy"`
AutoRemove bool `json:"AutoRemove"`
VolumeDriver string `json:"VolumeDriver"`
VolumesFrom []interface{} `json:"VolumesFrom"`
CapAdd interface{} `json:"CapAdd"`
CapDrop interface{} `json:"CapDrop"`
DNS interface{} `json:"Dns"`
DNSOptions interface{} `json:"DnsOptions"`
DNSSearch interface{} `json:"DnsSearch"`
ExtraHosts interface{} `json:"ExtraHosts"`
GroupAdd interface{} `json:"GroupAdd"`
IpcMode string `json:"IpcMode"`
Cgroup string `json:"Cgroup"`
Links interface{} `json:"Links"`
OomScoreAdj int `json:"OomScoreAdj"`
PidMode string `json:"PidMode"`
Privileged bool `json:"Privileged"`
PublishAllPorts bool `json:"PublishAllPorts"`
ReadonlyRootfs bool `json:"ReadonlyRootfs"`
SecurityOpt interface{} `json:"SecurityOpt"`
UTSMode string `json:"UTSMode"`
UsernsMode string `json:"UsernsMode"`
ShmSize int `json:"ShmSize"`
Runtime string `json:"Runtime"`
ConsoleSize []int `json:"ConsoleSize"`
Isolation string `json:"Isolation"`
CPUShares int `json:"CpuShares"`
Memory int `json:"Memory"`
NanoCpus int `json:"NanoCpus"`
CgroupParent string `json:"CgroupParent"`
BlkioWeight int `json:"BlkioWeight"`
BlkioWeightDevice interface{} `json:"BlkioWeightDevice"`
BlkioDeviceReadBps interface{} `json:"BlkioDeviceReadBps"`
BlkioDeviceWriteBps interface{} `json:"BlkioDeviceWriteBps"`
BlkioDeviceReadIOps interface{} `json:"BlkioDeviceReadIOps"`
BlkioDeviceWriteIOps interface{} `json:"BlkioDeviceWriteIOps"`
CPUPeriod int `json:"CpuPeriod"`
CPUQuota int `json:"CpuQuota"`
CPURealtimePeriod int `json:"CpuRealtimePeriod"`
CPURealtimeRuntime int `json:"CpuRealtimeRuntime"`
CpusetCpus string `json:"CpusetCpus"`
CpusetMems string `json:"CpusetMems"`
Devices interface{} `json:"Devices"`
DeviceCgroupRules interface{} `json:"DeviceCgroupRules"`
DiskQuota int `json:"DiskQuota"`
KernelMemory int `json:"KernelMemory"`
MemoryReservation int `json:"MemoryReservation"`
MemorySwap int `json:"MemorySwap"`
MemorySwappiness interface{} `json:"MemorySwappiness"`
OomKillDisable bool `json:"OomKillDisable"`
PidsLimit int `json:"PidsLimit"`
Ulimits interface{} `json:"Ulimits"`
CPUCount int `json:"CpuCount"`
CPUPercent int `json:"CpuPercent"`
IOMaximumIOps int `json:"IOMaximumIOps"`
IOMaximumBandwidth int `json:"IOMaximumBandwidth"`
MaskedPaths []string `json:"MaskedPaths"`
ReadonlyPaths []string `json:"ReadonlyPaths"`
} `json:"HostConfig"`
GraphDriver struct {
Data struct {
LowerDir string `json:"LowerDir"`
MergedDir string `json:"MergedDir"`
UpperDir string `json:"UpperDir"`
WorkDir string `json:"WorkDir"`
} `json:"Data"`
Name string `json:"Name"`
} `json:"GraphDriver"`
Mounts []struct {
Type string `json:"Type"`
Name string `json:"Name,omitempty"`
Source string `json:"Source"`
Destination string `json:"Destination"`
Driver string `json:"Driver,omitempty"`
Mode string `json:"Mode"`
RW bool `json:"RW"`
Propagation string `json:"Propagation"`
} `json:"Mounts"`
Config struct {
Hostname string `json:"Hostname"`
Domainname string `json:"Domainname"`
User string `json:"User"`
AttachStdin bool `json:"AttachStdin"`
AttachStdout bool `json:"AttachStdout"`
AttachStderr bool `json:"AttachStderr"`
Tty bool `json:"Tty"`
OpenStdin bool `json:"OpenStdin"`
StdinOnce bool `json:"StdinOnce"`
Env []string `json:"Env"`
Cmd []string `json:"Cmd"`
Image string `json:"Image"`
Volumes struct {
APIBundle struct {
} `json:"/api-bundle"`
App struct {
} `json:"/app"`
} `json:"Volumes"`
WorkingDir string `json:"WorkingDir"`
Entrypoint interface{} `json:"Entrypoint"`
OnBuild interface{} `json:"OnBuild"`
Labels struct {
ComDockerComposeOneoff string `json:"com.docker.compose.oneoff"`
ComDockerComposeProject string `json:"com.docker.compose.project"`
ComDockerComposeService string `json:"com.docker.compose.service"`
ComDockerComposeSlug string `json:"com.docker.compose.slug"`
ComDockerComposeVersion string `json:"com.docker.compose.version"`
} `json:"Labels"`
} `json:"Config"`
NetworkSettings struct {
Bridge string `json:"Bridge"`
SandboxID string `json:"SandboxID"`
HairpinMode bool `json:"HairpinMode"`
LinkLocalIPv6Address string `json:"LinkLocalIPv6Address"`
LinkLocalIPv6PrefixLen int `json:"LinkLocalIPv6PrefixLen"`
Ports struct {
} `json:"Ports"`
SandboxKey string `json:"SandboxKey"`
SecondaryIPAddresses interface{} `json:"SecondaryIPAddresses"`
SecondaryIPv6Addresses interface{} `json:"SecondaryIPv6Addresses"`
EndpointID string `json:"EndpointID"`
Gateway string `json:"Gateway"`
GlobalIPv6Address string `json:"GlobalIPv6Address"`
GlobalIPv6PrefixLen int `json:"GlobalIPv6PrefixLen"`
IPAddress string `json:"IPAddress"`
IPPrefixLen int `json:"IPPrefixLen"`
IPv6Gateway string `json:"IPv6Gateway"`
MacAddress string `json:"MacAddress"`
Networks struct {
ApdevDefault struct {
IPAMConfig interface{} `json:"IPAMConfig"`
Links interface{} `json:"Links"`
Aliases []string `json:"Aliases"`
NetworkID string `json:"NetworkID"`
EndpointID string `json:"EndpointID"`
Gateway string `json:"Gateway"`
IPAddress string `json:"IPAddress"`
IPPrefixLen int `json:"IPPrefixLen"`
IPv6Gateway string `json:"IPv6Gateway"`
GlobalIPv6Address string `json:"GlobalIPv6Address"`
GlobalIPv6PrefixLen int `json:"GlobalIPv6PrefixLen"`
MacAddress string `json:"MacAddress"`
DriverOpts interface{} `json:"DriverOpts"`
} `json:"apdev_default"`
} `json:"Networks"`
} `json:"NetworkSettings"`
}
// ContainerCliStat is a stat object returned by the CLI docker stat command
type ContainerCliStat struct {
BlockIO string `json:"BlockIO"`
CPUPerc string `json:"CPUPerc"`
Container string `json:"Container"`
ID string `json:"ID"`
MemPerc string `json:"MemPerc"`
MemUsage string `json:"MemUsage"`
Name string `json:"Name"`
NetIO string `json:"NetIO"`
PIDs string `json:"PIDs"`
}
// GetDisplayStrings returns the dispaly string of Container
func (c *Container) GetDisplayStrings(isFocused bool) []string {
return []string{c.GetDisplayStatus(), utils.ColoredString(c.Name, color.FgWhite), c.GetDisplayCPUPerc()}
}
// GetDisplayStatus returns the colored status of the container
func (c *Container) GetDisplayStatus() string {
state := c.Container.State
if c.Container.State == "exited" {
state += " (" + strconv.Itoa(c.Details.State.ExitCode) + ")"
}
return utils.ColoredString(state, c.GetColor())
}
// GetDisplayCPUPerc colors the cpu percentage based on how extreme it is
func (c *Container) GetDisplayCPUPerc() string {
stats := c.CLIStats
if stats.CPUPerc == "" {
return ""
}
percentage, err := strconv.ParseFloat(strings.TrimSuffix(stats.CPUPerc, "%"), 32)
if err != nil {
// probably complaining about not being able to convert '--'
return ""
}
var clr color.Attribute
if percentage > 90 {
clr = color.FgRed
} else if percentage > 50 {
clr = color.FgYellow
} else {
clr = color.FgWhite
}
return utils.ColoredString(stats.CPUPerc, clr)
}
// ProducingLogs tells us whether we should bother checking a container's logs
func (c *Container) ProducingLogs() bool {
return c.Container.State == "running" && !(c.Details.HostConfig.LogConfig.Type == "none")
}
// GetColor Container color
func (c *Container) GetColor() color.Attribute {
switch c.Container.State {
case "exited":
if c.Details.State.ExitCode == 0 {
return color.FgBlue
}
return color.FgRed
case "created":
return color.FgCyan
case "running":
return color.FgGreen
case "paused":
return color.FgYellow
case "dead":
return color.FgRed
case "restarting":
return color.FgBlue
case "removing":
return color.FgMagenta
default:
return color.FgWhite
}
}
// Remove removes the container
func (c *Container) Remove(options container.RemoveOptions) error {
c.Log.Warn(fmt.Sprintf("removing container %s", c.Name))
func (c *Container) Remove(options types.ContainerRemoveOptions) error {
if err := c.Client.ContainerRemove(context.Background(), c.ID, options); err != nil {
if strings.Contains(err.Error(), "Stop the container before attempting removal or force remove") {
return ComplexError{
@ -57,70 +345,58 @@ 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))
return c.Client.ContainerStop(context.Background(), c.ID, container.StopOptions{})
}
// Pause pauses the container
func (c *Container) Pause() error {
c.Log.Warn(fmt.Sprintf("pausing container %s", c.Name))
return c.Client.ContainerPause(context.Background(), c.ID)
}
// Unpause unpauses the container
func (c *Container) Unpause() error {
c.Log.Warn(fmt.Sprintf("unpausing container %s", c.Name))
return c.Client.ContainerUnpause(context.Background(), c.ID)
return c.Client.ContainerStop(context.Background(), c.ID, nil)
}
// Restart restarts the container
func (c *Container) Restart() error {
c.Log.Warn(fmt.Sprintf("restarting container %s", c.Name))
return c.Client.ContainerRestart(context.Background(), c.ID, container.StopOptions{})
return c.Client.ContainerRestart(context.Background(), c.ID, nil)
}
// Attach attaches the container
func (c *Container) Attach() (*exec.Cmd, error) {
if !c.DetailsLoaded() {
return nil, errors.New(c.Tr.WaitingForContainerInfo)
}
// verify that we can in fact attach to this container
if !c.Details.Config.OpenStdin {
return nil, errors.New(c.Tr.UnattachableContainerError)
return nil, errors.New("Container does not support attaching. You must either run the service with the '-it' flag or use `stdin_open: true, tty: true` in the docker-compose.yml file")
}
if c.Container.State == "exited" {
return nil, errors.New(c.Tr.CannotAttachStoppedContainerError)
}
c.Log.Warn(fmt.Sprintf("attaching to container %s", c.Name))
// TODO: use SDK
cmd := c.OSCommand.NewCmd("docker", "attach", "--sig-proxy=false", c.ID)
cmd := c.OSCommand.PrepareSubProcess("docker", "attach", "--sig-proxy=false", c.ID)
return cmd, nil
}
// Top returns process information
func (c *Container) Top(ctx context.Context) (container.TopResponse, error) {
detail, err := c.Inspect()
if err != nil {
return container.TopResponse{}, err
func (c *Container) Top() (types.ContainerProcessList, error) {
return c.Client.ContainerTop(context.Background(), c.ID, []string{})
}
// EraseOldHistory removes any history before the user-specified max duration
func (c *Container) EraseOldHistory() {
if c.Config.UserConfig.Stats.MaxDuration == 0 {
return
}
// check container status
if !detail.State.Running {
return container.TopResponse{}, errors.New("container is not running")
for i, stat := range c.StatHistory {
if time.Since(stat.RecordedAt) < c.Config.UserConfig.Stats.MaxDuration {
c.StatHistory = c.StatHistory[i:]
return
}
}
}
return c.Client.ContainerTop(ctx, c.ID, []string{})
// ViewLogs attaches to a subprocess viewing the container's logs
func (c *Container) ViewLogs() (*exec.Cmd, error) {
templateString := c.OSCommand.Config.UserConfig.CommandTemplates.ViewContainerLogs
command := utils.ApplyTemplate(
templateString,
c.DockerCommand.NewCommandObject(CommandObject{Container: c}),
)
cmd := c.OSCommand.ExecutableFromString(command)
c.OSCommand.PrepareForChildren(cmd)
return cmd, nil
}
// PruneContainers prunes containers
@ -129,24 +405,26 @@ func (c *DockerCommand) PruneContainers() error {
return err
}
// TTYLogsCommand returns a command for obtaining the logs from a TTY container
func (c *Container) TTYLogsCommand() *exec.Cmd {
command := utils.ApplyTemplate(
c.Config.UserConfig.CommandTemplates.ContainerTTYLogs,
c.DockerCommand.NewCommandObject(CommandObject{Container: c}),
)
return c.OSCommand.RunCustomCommand(command)
}
// Inspect returns details about the container
func (c *Container) Inspect() (container.InspectResponse, error) {
func (c *Container) Inspect() (types.ContainerJSON, error) {
return c.Client.ContainerInspect(context.Background(), c.ID)
}
// RenderTop returns details about the container
func (c *Container) RenderTop(ctx context.Context) (string, error) {
result, err := c.Top(ctx)
func (c *Container) RenderTop() (string, error) {
result, err := c.Client.ContainerTop(context.Background(), c.ID, []string{})
if err != nil {
return "", err
}
return utils.RenderTable(append([][]string{result.Titles}, result.Processes...))
}
// DetailsLoaded tells us whether we have yet loaded the details for a container.
// Sometimes it takes some time for a container to have its details loaded
// after it starts.
func (c *Container) DetailsLoaded() bool {
return c.Details.ContainerJSONBase != nil
}

View file

@ -1,23 +1,21 @@
package commands
import (
"encoding/json"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/jesseduffield/asciigraph"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/mcuadros/go-lookup"
)
// RecordedStats contains both the container stats we've received from docker, and our own derived stats from those container stats. When configuring a graph, you're basically specifying the path of a value in this struct
type RecordedStats struct {
ClientStats ContainerStats
DerivedStats DerivedStats
RecordedAt time.Time
}
// DerivedStats contains some useful stats that we've calculated based on the raw container stats that we got back from docker
type DerivedStats struct {
CPUPercentage float64
MemoryPercentage float64
}
// ContainerStats autogenerated at https://mholt.github.io/json-to-go/
type ContainerStats struct {
Read time.Time `json:"read"`
@ -45,9 +43,10 @@ type ContainerStats struct {
IoTimeRecursive []interface{} `json:"io_time_recursive"`
SectorsRecursive []interface{} `json:"sectors_recursive"`
} `json:"blkio_stats"`
NumProcs int `json:"num_procs"`
StorageStats struct{} `json:"storage_stats"`
CPUStats struct {
NumProcs int `json:"num_procs"`
StorageStats struct {
} `json:"storage_stats"`
CPUStats struct {
CPUUsage struct {
TotalUsage int64 `json:"total_usage"`
PercpuUsage []int64 `json:"percpu_usage"`
@ -138,8 +137,9 @@ type ContainerStats struct {
func (s *ContainerStats) CalculateContainerCPUPercentage() float64 {
cpuUsageDelta := s.CPUStats.CPUUsage.TotalUsage - s.PrecpuStats.CPUUsage.TotalUsage
cpuTotalUsageDelta := s.CPUStats.SystemCPUUsage - s.PrecpuStats.SystemCPUUsage
numberOfCores := len(s.CPUStats.CPUUsage.PercpuUsage)
value := float64(cpuUsageDelta*100) / float64(cpuTotalUsageDelta)
value := float64(cpuUsageDelta*100) * float64(numberOfCores) / float64(cpuTotalUsageDelta)
if math.IsNaN(value) {
return 0
}
@ -148,6 +148,7 @@ func (s *ContainerStats) CalculateContainerCPUPercentage() float64 {
// CalculateContainerMemoryUsage calculates the memory usage of the container as a percent of total available memory
func (s *ContainerStats) CalculateContainerMemoryUsage() float64 {
value := float64(s.MemoryStats.Usage*100) / float64(s.MemoryStats.Limit)
if math.IsNaN(value) {
return 0
@ -155,34 +156,128 @@ func (s *ContainerStats) CalculateContainerMemoryUsage() float64 {
return value
}
func (c *Container) appendStats(stats *RecordedStats, maxDuration time.Duration) {
c.StatsMutex.Lock()
defer c.StatsMutex.Unlock()
// RenderStats returns a string containing the rendered stats of the container
func (c *Container) RenderStats(viewWidth int) (string, error) {
history := c.StatHistory
if len(history) == 0 {
return "", nil
}
currentStats := history[len(history)-1]
c.StatHistory = append(c.StatHistory, stats)
c.eraseOldHistory(maxDuration)
}
// eraseOldHistory removes any history before the user-specified max duration
func (c *Container) eraseOldHistory(maxDuration time.Duration) {
if maxDuration == 0 {
return
graphSpecs := c.Config.UserConfig.Stats.Graphs
graphs := make([]string, len(graphSpecs))
for i, spec := range graphSpecs {
graph, err := c.PlotGraph(spec, viewWidth-10)
if err != nil {
return "", err
}
graphs[i] = utils.ColoredString(graph, utils.GetColorAttribute(spec.Color))
}
for i, stat := range c.StatHistory {
if time.Since(stat.RecordedAt) < maxDuration {
c.StatHistory = c.StatHistory[i:]
return
pidsCount := fmt.Sprintf("PIDs: %d", currentStats.ClientStats.PidsStats.Current)
dataReceived := fmt.Sprintf("Traffic received: %s", utils.FormatDecimalBytes(currentStats.ClientStats.Networks.Eth0.RxBytes))
dataSent := fmt.Sprintf("Traffic sent: %s", utils.FormatDecimalBytes(currentStats.ClientStats.Networks.Eth0.TxBytes))
originalJSON, err := json.MarshalIndent(currentStats, "", " ")
if err != nil {
return "", err
}
contents := fmt.Sprintf("\n\n%s\n\n%s\n\n%s\n%s\n\n%s",
utils.ColoredString(strings.Join(graphs, "\n\n"), color.FgGreen),
pidsCount,
dataReceived,
dataSent,
string(originalJSON),
)
return contents, nil
}
// PlotGraph returns the plotted graph based on the graph spec and the stat history
func (c *Container) PlotGraph(spec config.GraphConfig, width int) (string, error) {
data := make([]float64, len(c.StatHistory))
max := spec.Max
min := spec.Min
for i, stats := range c.StatHistory {
value, err := lookup.LookupString(stats, spec.StatPath)
if err != nil {
return "Could not find key: " + spec.StatPath, nil
}
floatValue, err := getFloat(value.Interface())
if err != nil {
return "", err
}
if spec.MinType == "" {
if i == 0 {
min = floatValue
} else {
if floatValue < min {
min = floatValue
}
}
}
if spec.MaxType == "" {
if i == 0 {
max = floatValue
} else {
if floatValue > max {
max = floatValue
}
}
}
data[i] = floatValue
}
return asciigraph.Plot(
data,
asciigraph.Height(spec.Height),
asciigraph.Width(width),
asciigraph.Min(min),
asciigraph.Max(max),
asciigraph.Caption(fmt.Sprintf("%s: %0.2f (%v)", spec.Caption, data[len(data)-1], time.Since(c.StatHistory[0].RecordedAt))),
), nil
}
// from Dave C's answer at https://stackoverflow.com/questions/20767724/converting-unknown-interface-to-float64-in-golang
func getFloat(unk interface{}) (float64, error) {
floatType := reflect.TypeOf(float64(0))
stringType := reflect.TypeOf("")
switch i := unk.(type) {
case float64:
return i, nil
case float32:
return float64(i), nil
case int64:
return float64(i), nil
case int32:
return float64(i), nil
case int:
return float64(i), nil
case uint64:
return float64(i), nil
case uint32:
return float64(i), nil
case uint:
return float64(i), nil
case string:
return strconv.ParseFloat(i, 64)
default:
v := reflect.ValueOf(unk)
v = reflect.Indirect(v)
if v.Type().ConvertibleTo(floatType) {
fv := v.Convert(floatType)
return fv.Float(), nil
} else if v.Type().ConvertibleTo(stringType) {
sv := v.Convert(stringType)
s := sv.String()
return strconv.ParseFloat(s, 64)
} else {
return math.NaN(), fmt.Errorf("Can't convert %v to float64", v.Type())
}
}
}
func (c *Container) GetLastStats() (*RecordedStats, bool) {
c.StatsMutex.Lock()
defer c.StatsMutex.Unlock()
history := c.StatHistory
if len(history) == 0 {
return nil, false
}
return history[len(history)-1], true
}

View file

@ -1,17 +0,0 @@
package commands
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCalculateContainerCPUPercentage(t *testing.T) {
container := &ContainerStats{}
container.CPUStats.CPUUsage.TotalUsage = 10
container.CPUStats.SystemCPUUsage = 10
container.PrecpuStats.CPUUsage.TotalUsage = 5
container.PrecpuStats.SystemCPUUsage = 2
assert.EqualValues(t, 62.5, container.CalculateContainerCPUPercentage())
}

View file

@ -5,35 +5,23 @@ import (
"context"
"encoding/json"
"fmt"
"io"
ogLog "log"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
cliconfig "github.com/docker/cli/cli/config"
ddocker "github.com/docker/cli/cli/context/docker"
ctxstore "github.com/docker/cli/cli/context/store"
"github.com/docker/docker/api/types/container"
"github.com/acarl005/stripansi"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/imdario/mergo"
"github.com/jesseduffield/lazydocker/pkg/commands/ssh"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/i18n"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
const (
dockerHostEnvKey = "DOCKER_HOST"
)
// DockerCommand is our main docker interface
// DockerCommand is our main git interface
type DockerCommand struct {
Log *logrus.Entry
OSCommand *OSCommand
@ -41,17 +29,17 @@ 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
Closers []io.Closer
ErrorChan chan error
ContainerMutex sync.Mutex
ServiceMutex sync.Mutex
Services []*Service
Containers []*Container
// DisplayContainers is the array of containers we will display in the containers panel. If Gui.ShowAllContainers is false, this will only be those containers which aren't based on a service. This reduces clutter and duplication in the UI
DisplayContainers []*Container
Images []*Image
Volumes []*Volume
}
var _ io.Closer = &DockerCommand{}
// LimitedDockerCommand is a stripped-down DockerCommand with just the methods the container/service/image might need
type LimitedDockerCommand interface {
NewCommandObject(CommandObject) CommandObject
@ -62,72 +50,20 @@ type CommandObject struct {
DockerCompose string
Service *Service
Container *Container
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)
}
mergo.Merge(&defaultObj, obj)
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 git commands
func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*DockerCommand, error) {
dockerHost, err := determineDockerHost()
cli, err := client.NewEnvClient()
if err != nil {
ogLog.Printf("> could not determine host %v", err)
}
// NOTE: Inject the determined docker host to the environment. This allows the
// `SSHHandler.HandleSSHDockerHost()` to create a local unix socket tunneled
// over SSH to the specified ssh host.
if strings.HasPrefix(dockerHost, "ssh://") {
os.Setenv(dockerHostEnvKey, dockerHost)
}
tunnelCloser, err := ssh.NewSSHHandler(osCommand).HandleSSHDockerHost()
if err != nil {
ogLog.Fatal(err)
}
// Retrieve the docker host from the environment which could have been set by
// the `SSHHandler.HandleSSHDockerHost()` and override `dockerHost`.
dockerHostFromEnv := os.Getenv(dockerHostEnvKey)
if dockerHostFromEnv != "" {
dockerHost = dockerHostFromEnv
}
cli, err := newDockerClient(dockerHost)
if err != nil {
ogLog.Fatal(err)
return nil, err
}
dockerCommand := &DockerCommand{
@ -138,10 +74,14 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat
Client: cli,
ErrorChan: errorChan,
InDockerComposeProject: true,
Closers: []io.Closer{tunnelCloser},
}
dockerCommand.setDockerComposeCommand(config)
command := utils.ApplyTemplate(
config.UserConfig.CommandTemplates.CheckDockerComposeConfig,
dockerCommand.NewCommandObject(CommandObject{}),
)
log.Warn(command)
err = osCommand.RunCommand(
utils.ApplyTemplate(
@ -154,49 +94,70 @@ 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 != ""
// MonitorContainerStats is a function
func (c *DockerCommand) MonitorContainerStats() {
go c.MonitorCLIContainerStats()
go c.MonitorClientContainerStats()
}
func (c *DockerCommand) setDockerComposeCommand(config *config.AppConfig) {
if config.UserConfig.CommandTemplates.DockerCompose != "docker compose" {
// MonitorCLIContainerStats monitors a stream of container stats and updates the containers as each new stats object is received
func (c *DockerCommand) MonitorCLIContainerStats() {
command := `docker stats --all --no-trunc --format '{{json .}}'`
cmd := c.OSCommand.RunCustomCommand(command)
r, err := cmd.StdoutPipe()
if err != nil {
c.ErrorChan <- err
return
}
// it's possible that a user is still using docker-compose, so we'll check if 'docker comopose' is available, and if not, we'll fall back to 'docker-compose'
err := c.OSCommand.RunCommand("docker compose version")
if err != nil {
config.UserConfig.CommandTemplates.DockerCompose = "docker-compose"
cmd.Start()
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
var stats ContainerCliStat
// need to strip ANSI codes because uses escape sequences to clear the screen with each refresh
cleanString := stripansi.Strip(scanner.Text())
if err := json.Unmarshal([]byte(cleanString), &stats); err != nil {
c.ErrorChan <- err
return
}
c.ContainerMutex.Lock()
for _, container := range c.Containers {
if container.ID == stats.ID {
container.CLIStats = stats
}
}
c.ContainerMutex.Unlock()
}
cmd.Wait()
return
}
// MonitorClientContainerStats is a function
func (c *DockerCommand) MonitorClientContainerStats() {
// periodically loop through running containers and see if we need to create a monitor goroutine for any
// every second we check if we need to spawn a new goroutine
for range time.Tick(time.Second) {
for _, container := range c.Containers {
if !container.MonitoringStats {
go c.createClientStatMonitor(container)
}
}
}
}
func (c *DockerCommand) Close() error {
return utils.CloseMany(c.Closers)
}
func (c *DockerCommand) CreateClientStatMonitor(container *Container) {
func (c *DockerCommand) createClientStatMonitor(container *Container) {
container.MonitoringStats = true
stream, err := c.Client.ContainerStats(context.Background(), container.ID, true)
if err != nil {
// not creating error panel because if we've disconnected from docker we'll
// have already created an error panel
c.Log.Error(err)
container.MonitoringStats = false
c.ErrorChan <- err
return
}
@ -206,9 +167,9 @@ func (c *DockerCommand) CreateClientStatMonitor(container *Container) {
for scanner.Scan() {
data := scanner.Bytes()
var stats ContainerStats
_ = json.Unmarshal(data, &stats)
json.Unmarshal(data, &stats)
recordedStats := &RecordedStats{
recordedStats := RecordedStats{
ClientStats: stats,
DerivedStats: DerivedStats{
CPUPercentage: stats.CalculateContainerCPUPercentage(),
@ -217,152 +178,74 @@ func (c *DockerCommand) CreateClientStatMonitor(container *Container) {
RecordedAt: time.Now(),
}
container.appendStats(recordedStats, c.Config.UserConfig.Stats.MaxDuration)
c.ContainerMutex.Lock()
container.StatHistory = append(container.StatHistory, recordedStats)
container.EraseOldHistory()
c.ContainerMutex.Unlock()
}
container.MonitoringStats = false
return
}
func (c *DockerCommand) RefreshContainersAndServices(currentContainers []*Container) ([]*Container, []*Service, error) {
// RefreshContainersAndServices returns a slice of docker containers
func (c *DockerCommand) RefreshContainersAndServices() error {
c.ServiceMutex.Lock()
defer c.ServiceMutex.Unlock()
containers, err := c.GetContainers(currentContainers)
currentServices := c.Services
containers, err := c.GetContainers()
if err != nil {
return nil, nil, err
return 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 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
var displayContainers []*Container
if c.Config.UserConfig.Gui.ShowAllContainers {
displayContainers = containers
} else {
displayContainers = c.obtainStandaloneContainers(containers, services)
}
seen := make(map[serviceKey]bool)
services := make([]*Service, 0, len(containers))
for _, ctr := range containers {
if ctr.ServiceName == "" || ctr.OneOff {
continue
// sort services first by whether they have a linked container, and second by alphabetical order
sort.Slice(services, func(i, j int) bool {
if services[i].Container != nil && services[j].Container == nil {
return true
}
key := serviceKey{project: ctr.ProjectName, service: ctr.ServiceName}
if seen[key] {
continue
if services[i].Container == nil && services[j].Container != nil {
return false
}
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
}
return services[i].Name < services[j].Name
})
// 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
}
}
c.Containers = containers
c.Services = services
c.DisplayContainers = displayContainers
// 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
return nil
}
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 {
service.Container = ctr
for _, container := range containers {
if container.ServiceID != "" && container.ServiceID == service.ID {
service.Container = container
continue L
}
}
@ -370,24 +253,41 @@ L:
}
}
func (c *DockerCommand) obtainStandaloneContainers(containers []*Container, services []*Service) []*Container {
standaloneContainers := []*Container{}
L:
for _, container := range containers {
for _, service := range services {
if container.ServiceID != "" && container.ServiceID == service.ID {
continue L
}
}
standaloneContainers = append(standaloneContainers, container)
}
return standaloneContainers
}
// GetContainers gets the docker containers
func (c *DockerCommand) GetContainers(existingContainers []*Container) ([]*Container, error) {
func (c *DockerCommand) GetContainers() ([]*Container, error) {
c.ContainerMutex.Lock()
defer c.ContainerMutex.Unlock()
containers, err := c.Client.ContainerList(context.Background(), container.ListOptions{All: true})
existingContainers := c.Containers
containers, err := c.Client.ContainerList(context.Background(), types.ContainerListOptions{All: true})
if err != nil {
return nil, err
}
ownContainers := make([]*Container, len(containers))
for i, ctr := range containers {
for i, container := range containers {
var newContainer *Container
// check if we already have data stored against the container
// check if we already data stored against the container
for _, existingContainer := range existingContainers {
if existingContainer.ID == ctr.ID {
if existingContainer.ID == container.ID {
newContainer = existingContainer
break
}
@ -396,36 +296,30 @@ func (c *DockerCommand) GetContainers(existingContainers []*Container) ([]*Conta
// initialise the container if it's completely new
if newContainer == nil {
newContainer = &Container{
ID: ctr.ID,
ID: container.ID,
Client: c.Client,
OSCommand: c.OSCommand,
Log: c.Log,
Config: c.Config,
DockerCommand: c,
Tr: c.Tr,
}
}
newContainer.Container = ctr
newContainer.Container = container
// if the container is made with a name label we will use that
if name, ok := ctr.Labels["name"]; ok {
if name, ok := container.Labels["name"]; ok {
newContainer.Name = name
} else {
if len(ctr.Names) > 0 {
newContainer.Name = strings.TrimLeft(ctr.Names[0], "/")
} else {
newContainer.Name = ctr.ID
}
newContainer.Name = strings.TrimLeft(container.Names[0], "/")
}
newContainer.ServiceName = ctr.Labels["com.docker.compose.service"]
newContainer.ProjectName = ctr.Labels["com.docker.compose.project"]
newContainer.ContainerNumber = ctr.Labels["com.docker.compose.container"]
newContainer.OneOff = ctr.Labels["com.docker.compose.oneoff"] == "True"
newContainer.ServiceName = container.Labels["com.docker.compose.service"]
newContainer.ServiceID = container.Labels["com.docker.compose.config-hash"]
newContainer.ProjectName = container.Labels["com.docker.compose.project"]
newContainer.ContainerNumber = container.Labels["com.docker.compose.container"]
ownContainers[i] = newContainer
}
c.SetContainerDetails(ownContainers)
return ownContainers, nil
}
@ -436,22 +330,22 @@ func (c *DockerCommand) GetServices() ([]*Service, error) {
}
composeCommand := c.Config.UserConfig.CommandTemplates.DockerCompose
output, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --services", composeCommand))
output, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --hash=*", composeCommand))
if err != nil {
return nil, err
}
// output looks like:
// service1
// service2
// service1 998d6d286b0499e0ff23d66302e720991a2asdkf9c30d0542034f610daf8a971
// service2 asdld98asdklasd9bccd02438de0994f8e19cbe691feb3755336ec5ca2c55971
lines := utils.SplitLines(output)
services := make([]*Service, len(lines))
for i, str := range lines {
arr := strings.Split(str, " ")
services[i] = &Service{
Name: str,
ID: c.LocalProjectName + "-" + str,
ProjectName: c.LocalProjectName,
Name: arr[0],
ID: arr[1],
OSCommand: c.OSCommand,
Log: c.Log,
DockerCommand: c,
@ -461,41 +355,43 @@ func (c *DockerCommand) GetServices() ([]*Service, error) {
return services, nil
}
func (c *DockerCommand) RefreshContainerDetails(containers []*Container) error {
// UpdateContainerDetails attaches the details returned from docker inspect to each of the containers
// this contains a bit more info than what you get from the go-docker client
func (c *DockerCommand) UpdateContainerDetails() error {
c.ContainerMutex.Lock()
defer c.ContainerMutex.Unlock()
c.SetContainerDetails(containers)
containers := c.Containers
ids := make([]string, len(containers))
for i, container := range containers {
ids[i] = container.ID
}
cmd := c.OSCommand.RunCustomCommand("docker inspect " + strings.Join(ids, " "))
output, err := cmd.CombinedOutput()
if err != nil {
return err
}
var details []*Details
if err := json.Unmarshal(output, &details); err != nil {
return err
}
for i, container := range containers {
container.Details = *details[i]
}
return nil
}
// Attaches the details returned from docker inspect to each of the containers
// this contains a bit more info than what you get from the go-docker client
func (c *DockerCommand) SetContainerDetails(containers []*Container) {
wg := sync.WaitGroup{}
for _, ctr := range containers {
ctr := ctr
wg.Add(1)
go func() {
details, err := c.Client.ContainerInspect(context.Background(), ctr.ID)
if err != nil {
c.Log.Error(err)
} else {
ctr.Details = details
}
wg.Done()
}()
}
wg.Wait()
}
// 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,80 +402,15 @@ 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 {
output = err.Error()
}
return output
}
// determineDockerHost tries to the determine the docker host that we should connect to
// in the following order of decreasing precedence:
// - value of "DOCKER_HOST" environment variable
// - host retrieved from the current context (specified via DOCKER_CONTEXT)
// - "default docker host" for the host operating system, otherwise
func determineDockerHost() (string, error) {
// If the docker host is explicitly set via the "DOCKER_HOST" environment variable,
// then its a no-brainer :shrug:
if os.Getenv("DOCKER_HOST") != "" {
return os.Getenv("DOCKER_HOST"), nil
}
currentContext := os.Getenv("DOCKER_CONTEXT")
if currentContext == "" {
cf, err := cliconfig.Load(cliconfig.Dir())
if err != nil {
return "", err
}
currentContext = cf.CurrentContext
}
// On some systems (windows) `default` is stored in the docker config as the currentContext.
if currentContext == "" || currentContext == "default" {
// If a docker context is neither specified via the "DOCKER_CONTEXT" environment variable nor via the
// $HOME/.docker/config file, then we fall back to connecting to the "default docker host" meant for
// the host operating system.
return defaultDockerHost, nil
}
storeConfig := ctxstore.NewConfig(
func() interface{} { return &ddocker.EndpointMeta{} },
ctxstore.EndpointTypeGetter(ddocker.DockerEndpoint, func() interface{} { return &ddocker.EndpointMeta{} }),
)
st := ctxstore.New(cliconfig.ContextStoreDir(), storeConfig)
md, err := st.GetMetadata(currentContext)
if err != nil {
return "", err
}
dockerEP, ok := md.Endpoints[ddocker.DockerEndpoint]
if !ok {
return "", err
}
dockerEPMeta, ok := dockerEP.(ddocker.EndpointMeta)
if !ok {
return "", fmt.Errorf("expected docker.EndpointMeta, got %T", dockerEP)
}
if dockerEPMeta.Host != "" {
return dockerEPMeta.Host, nil
}
// We might end up here, if the context was created with the `host` set to an empty value (i.e. '').
// For example:
// ```sh
// docker context create foo --docker "host="
// ```
// In such scenario, we mimic the `docker` cli and try to connect to the "default docker host".
return defaultDockerHost, nil
}

View file

@ -1,7 +0,0 @@
//go:build !windows
package commands
const (
defaultDockerHost = "unix:///var/run/docker.sock"
)

View file

@ -1,5 +0,0 @@
package commands
const (
defaultDockerHost = "npipe:////./pipe/docker_engine"
)

View file

@ -1,91 +0,0 @@
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

@ -1,7 +1,7 @@
package commands
import (
"io"
"io/ioutil"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/i18n"
@ -31,7 +31,7 @@ func NewDummyAppConfig() *config.AppConfig {
// NewDummyLog creates a new dummy Log for testing
func NewDummyLog() *logrus.Entry {
log := logrus.New()
log.Out = io.Discard
log.Out = ioutil.Discard
return log.WithField("test", "test")
}
@ -42,11 +42,10 @@ func NewDummyDockerCommand() *DockerCommand {
// NewDummyDockerCommandWithOSCommand creates a new dummy DockerCommand for testing
func NewDummyDockerCommandWithOSCommand(osCommand *OSCommand) *DockerCommand {
newAppConfig := NewDummyAppConfig()
return &DockerCommand{
Log: NewDummyLog(),
OSCommand: osCommand,
Tr: i18n.NewTranslationSet(NewDummyLog(), newAppConfig.UserConfig.Gui.Language),
Config: newAppConfig,
Tr: i18n.NewTranslationSet(NewDummyLog()),
Config: NewDummyAppConfig(),
}
}

View file

@ -0,0 +1,100 @@
// +build !windows
package commands
import (
"bufio"
"bytes"
"os"
"strings"
"unicode/utf8"
"github.com/go-errors/errors"
"github.com/jesseduffield/pty"
"github.com/mgutz/str"
)
// RunCommandWithOutputLiveWrapper runs a command and return every word that gets written in stdout
// Output is a function that executes by every word that gets read by bufio
// As return of output you need to give a string that will be written to stdin
// NOTE: If the return data is empty it won't written anything to stdin
func RunCommandWithOutputLiveWrapper(c *OSCommand, command string, output func(string) string) error {
splitCmd := str.ToArgv(command)
cmd := c.command(splitCmd[0], splitCmd[1:]...)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8")
var stderr bytes.Buffer
cmd.Stderr = &stderr
ptmx, err := pty.Start(cmd)
if err != nil {
return err
}
go func() {
scanner := bufio.NewScanner(ptmx)
scanner.Split(scanWordsWithNewLines)
for scanner.Scan() {
toOutput := strings.Trim(scanner.Text(), " ")
_, _ = ptmx.WriteString(output(toOutput))
}
}()
err = cmd.Wait()
ptmx.Close()
if err != nil {
return errors.New(stderr.String())
}
return nil
}
// scanWordsWithNewLines is a copy of bufio.ScanWords but this also captures new lines
// For specific comments about this function take a look at: bufio.ScanWords
func scanWordsWithNewLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
start := 0
for width := 0; start < len(data); start += width {
var r rune
r, width = utf8.DecodeRune(data[start:])
if !isSpace(r) {
break
}
}
for width, i := 0, start; i < len(data); i += width {
var r rune
r, width = utf8.DecodeRune(data[i:])
if isSpace(r) {
return i + width, data[start:i], nil
}
}
if atEOF && len(data) > start {
return len(data), data[start:], nil
}
return start, nil, nil
}
// isSpace is also copied from the bufio package and has been modified to also captures new lines
// For specific comments about this function take a look at: bufio.isSpace
func isSpace(r rune) bool {
if r <= '\u00FF' {
switch r {
case ' ', '\t', '\v', '\f':
return true
case '\u0085', '\u00A0':
return true
}
return false
}
if '\u2000' <= r && r <= '\u200a' {
return true
}
switch r {
case '\u1680', '\u2028', '\u2029', '\u202f', '\u205f', '\u3000':
return true
}
return false
}

View file

@ -0,0 +1,9 @@
// +build windows
package commands
// RunCommandWithOutputLiveWrapper runs a command live but because of windows compatibility this command can't be ran there
// TODO: Remove this hack and replace it with a proper way to run commands live on windows
func RunCommandWithOutputLiveWrapper(c *OSCommand, command string, output func(string) string) error {
return c.RunCommand(command)
}

View file

@ -4,12 +4,11 @@ import (
"context"
"strings"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/fatih/color"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
@ -18,15 +17,21 @@ type Image struct {
Name string
Tag string
ID string
Image image.Summary
Image types.ImageSummary
Client *client.Client
OSCommand *OSCommand
Log *logrus.Entry
DockerCommand LimitedDockerCommand
}
// GetDisplayStrings returns the display string of Image
func (i *Image) GetDisplayStrings(isFocused bool) []string {
return []string{utils.ColoredString(i.Name, color.FgWhite), utils.ColoredString(i.Tag, color.FgWhite), utils.FormatDecimalBytes(int(i.Image.Size))}
}
// Remove removes the image
func (i *Image) Remove(options image.RemoveOptions) error {
func (i *Image) Remove(options types.ImageRemoveOptions) error {
if _, err := i.Client.ImageRemove(context.Background(), i.ID, options); err != nil {
return err
}
@ -34,35 +39,41 @@ func (i *Image) Remove(options image.RemoveOptions) error {
return nil
}
func getHistoryResponseItemDisplayStrings(layer image.HistoryResponseItem) []string {
// Layer is a layer in an image's history
type Layer struct {
types.ImageHistory
}
// GetDisplayStrings returns the array of strings describing the layer
func (l *Layer) GetDisplayStrings(isFocused bool) []string {
tag := ""
if len(layer.Tags) > 0 {
tag = layer.Tags[0]
if len(l.Tags) > 0 {
tag = l.Tags[0]
}
id := strings.TrimPrefix(layer.ID, "sha256:")
id := strings.TrimPrefix(l.ID, "sha256:")
if len(id) > 10 {
id = id[0:10]
}
idColor := color.FgWhite
if id == "<missing>" {
idColor = color.FgBlue
idColor = color.FgBlack
}
dockerFileCommandPrefix := "/bin/sh -c #(nop) "
createdBy := layer.CreatedBy
if strings.Contains(layer.CreatedBy, dockerFileCommandPrefix) {
createdBy = strings.Trim(strings.TrimPrefix(layer.CreatedBy, dockerFileCommandPrefix), " ")
createdBy := l.CreatedBy
if strings.Contains(l.CreatedBy, dockerFileCommandPrefix) {
createdBy = strings.Trim(strings.TrimPrefix(l.CreatedBy, dockerFileCommandPrefix), " ")
split := strings.Split(createdBy, " ")
createdBy = utils.ColoredString(split[0], color.FgYellow) + " " + strings.Join(split[1:], " ")
}
createdBy = strings.Replace(createdBy, "\t", " ", -1)
createdBy = strings.ReplaceAll(createdBy, "\t", " ")
size := utils.FormatBinaryBytes(int(layer.Size))
size := utils.FormatBinaryBytes(int(l.Size))
sizeColor := color.FgWhite
if size == "0B" {
sizeColor = color.FgBlue
sizeColor = color.FgBlack
}
return []string{
@ -75,57 +86,49 @@ func getHistoryResponseItemDisplayStrings(layer image.HistoryResponseItem) []str
// RenderHistory renders the history of the image
func (i *Image) RenderHistory() (string, error) {
history, err := i.Client.ImageHistory(context.Background(), i.ID)
if err != nil {
return "", err
}
tableBody := lo.Map(history, func(layer image.HistoryResponseItem, _ int) []string {
return getHistoryResponseItemDisplayStrings(layer)
})
layers := make([]*Layer, len(history))
for i, layer := range history {
layers[i] = &Layer{layer}
}
headers := [][]string{{"ID", "TAG", "SIZE", "COMMAND"}}
table := append(headers, tableBody...)
return utils.RenderTable(table)
return utils.RenderList(layers, utils.WithHeader([]string{"ID", "TAG", "SIZE", "COMMAND"}))
}
// RefreshImages returns a slice of docker images
func (c *DockerCommand) RefreshImages() ([]*Image, error) {
images, err := c.Client.ImageList(context.Background(), image.ListOptions{})
// GetImages returns a slice of docker images
func (c *DockerCommand) GetImages() ([]*Image, error) {
images, err := c.Client.ImageList(context.Background(), types.ImageListOptions{})
if err != nil {
return nil, err
}
ownImages := make([]*Image, len(images))
for i, img := range images {
firstTag := ""
tags := img.RepoTags
for i, image := range images {
// func (cli *Client) ImageHistory(ctx context.Context, imageID string) ([]image.HistoryResponseItem, error)
name := "none"
tags := image.RepoTags
if len(tags) > 0 {
firstTag = tags[0]
name = tags[0]
}
nameParts := strings.Split(firstTag, ":")
nameParts := strings.Split(name, ":")
tag := ""
name := "none"
if len(nameParts) > 1 {
tag = nameParts[len(nameParts)-1]
name = strings.Join(nameParts[:len(nameParts)-1], ":")
for prefix, replacement := range c.Config.UserConfig.Replacements.ImageNamePrefixes {
if strings.HasPrefix(name, prefix) {
name = strings.Replace(name, prefix, replacement, 1)
break
}
}
tag = nameParts[1]
}
ownImages[i] = &Image{
ID: img.ID,
Name: name,
ID: image.ID,
Name: nameParts[0],
Tag: tag,
Image: img,
Image: image,
Client: c.Client,
OSCommand: c.OSCommand,
Log: c.Log,

View file

@ -1,54 +0,0 @@
package commands
import (
"context"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// Network : A docker Network
type Network struct {
Name string
Network network.Inspect
Client *client.Client
OSCommand *OSCommand
Log *logrus.Entry
DockerCommand LimitedDockerCommand
}
// RefreshNetworks gets the networks and stores them
func (c *DockerCommand) RefreshNetworks() ([]*Network, error) {
networks, err := c.Client.NetworkList(context.Background(), network.ListOptions{})
if err != nil {
return nil, err
}
ownNetworks := make([]*Network, len(networks))
for i, nw := range networks {
ownNetworks[i] = &Network{
Name: nw.Name,
Network: nw,
Client: c.Client,
OSCommand: c.OSCommand,
Log: c.Log,
DockerCommand: c,
}
}
return ownNetworks, nil
}
// PruneNetworks prunes networks
func (c *DockerCommand) PruneNetworks() error {
_, err := c.Client.NetworksPrune(context.Background(), filters.Args{})
return err
}
// Remove removes the network
func (v *Network) Remove() error {
return v.Client.NetworkRemove(context.Background(), v.Name)
}

View file

@ -1,51 +1,56 @@
package commands
import (
"context"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/kill"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/mgutz/str"
"github.com/sirupsen/logrus"
gitconfig "github.com/tcnksm/go-gitconfig"
)
// Platform stores the os state
type Platform struct {
os string
shell string
shellArg string
openCommand string
openLinkCommand string
os string
shell string
shellArg string
escapedQuote string
openCommand string
openLinkCommand string
fallbackEscapedQuote string
}
// OSCommand holds all the os commands
type OSCommand struct {
Log *logrus.Entry
Platform *Platform
Config *config.AppConfig
command func(string, ...string) *exec.Cmd
getenv func(string) string
Log *logrus.Entry
Platform *Platform
Config *config.AppConfig
command func(string, ...string) *exec.Cmd
getGlobalGitConfig func(string) (string, error)
getenv func(string) string
}
// NewOSCommand os command runner
func NewOSCommand(log *logrus.Entry, config *config.AppConfig) *OSCommand {
return &OSCommand{
Log: log,
Platform: getPlatform(),
Config: config,
command: exec.Command,
getenv: os.Getenv,
Log: log,
Platform: getPlatform(),
Config: config,
command: exec.Command,
getGlobalGitConfig: gitconfig.Global,
getenv: os.Getenv,
}
}
@ -59,16 +64,7 @@ func (c *OSCommand) SetCommand(cmd func(string, ...string) *exec.Cmd) {
func (c *OSCommand) RunCommandWithOutput(command string) (string, error) {
cmd := c.ExecutableFromString(command)
before := time.Now()
output, err := sanitisedCommandOutput(cmd.Output())
c.Log.Warn(fmt.Sprintf("'%s': %s", command, time.Since(before)))
return output, err
}
// RunCommandWithOutput wrapper around commands returning their output and error
func (c *OSCommand) RunCommandWithOutputContext(ctx context.Context, command string) (string, error) {
cmd := c.ExecutableFromStringContext(ctx, command)
before := time.Now()
output, err := sanitisedCommandOutput(cmd.Output())
output, err := sanitisedCommandOutput(cmd.CombinedOutput())
c.Log.Warn(fmt.Sprintf("'%s': %s", command, time.Since(before)))
return output, err
}
@ -84,41 +80,41 @@ func (c *OSCommand) RunExecutable(cmd *exec.Cmd) error {
return err
}
// ExecutableFromString takes a string like `docker ps -a` and returns an executable command for it
// ExecutableFromString takes a string like `git status` and returns an executable command for it
func (c *OSCommand) ExecutableFromString(commandStr string) *exec.Cmd {
splitCmd := str.ToArgv(commandStr)
return c.NewCmd(splitCmd[0], splitCmd[1:]...)
// c.Log.Info(splitCmd)
return c.command(splitCmd[0], splitCmd[1:]...)
}
// Same as ExecutableFromString but cancellable via a context
func (c *OSCommand) ExecutableFromStringContext(ctx context.Context, commandStr string) *exec.Cmd {
splitCmd := str.ToArgv(commandStr)
return exec.CommandContext(ctx, splitCmd[0], splitCmd[1:]...)
// RunCommandWithOutputLive runs RunCommandWithOutputLiveWrapper
func (c *OSCommand) RunCommandWithOutputLive(command string, output func(string) string) error {
return RunCommandWithOutputLiveWrapper(c, command, output)
}
func (c *OSCommand) NewCmd(cmdName string, commandArgs ...string) *exec.Cmd {
cmd := c.command(cmdName, commandArgs...)
cmd.Env = os.Environ()
return cmd
}
// DetectUnamePass detect a username / password question in a command
// ask is a function that gets executen when this function detect you need to fillin a password
// The ask argument will be "username" or "password" and expects the user's password or username back
func (c *OSCommand) DetectUnamePass(command string, ask func(string) string) error {
ttyText := ""
errMessage := c.RunCommandWithOutputLive(command, func(word string) string {
ttyText = ttyText + " " + word
func (c *OSCommand) NewCommandStringWithShell(commandStr string) string {
var quotedCommand string
// Windows does not seem to like quotes around the command
if c.Platform.os == "windows" {
quotedCommand = strings.NewReplacer(
"^", "^^",
"&", "^&",
"|", "^|",
"<", "^<",
">", "^>",
"%", "^%",
).Replace(commandStr)
} else {
quotedCommand = c.Quote(commandStr)
}
prompts := map[string]string{
"password": `Password\s*for\s*'.+':`,
"username": `Username\s*for\s*'.+':`,
}
return fmt.Sprintf("%s %s %s", c.Platform.shell, c.Platform.shellArg, quotedCommand)
for askFor, pattern := range prompts {
if match, _ := regexp.MatchString(pattern, ttyText); match {
ttyText = ""
return ask(askFor)
}
}
return ""
})
return errMessage
}
// RunCommand runs a command and just returns the error
@ -139,16 +135,25 @@ func (c *OSCommand) FileType(path string) string {
return "file"
}
// RunDirectCommand wrapper around direct commands
func (c *OSCommand) RunDirectCommand(command string) (string, error) {
c.Log.WithField("command", command).Info("RunDirectCommand")
return sanitisedCommandOutput(
c.command(c.Platform.shell, c.Platform.shellArg, command).
CombinedOutput(),
)
}
func sanitisedCommandOutput(output []byte, err error) (string, error) {
outputString := string(output)
if err != nil {
// errors like 'exit status 1' are not very useful so we'll create an error
// from stderr if we got an ExitError
exitError, ok := err.(*exec.ExitError)
if ok {
return outputString, errors.New(string(exitError.Stderr))
// from the combined output
if outputString == "" {
return "", WrapError(err)
}
return "", WrapError(err)
return outputString, errors.New(outputString)
}
return outputString, nil
}
@ -180,7 +185,11 @@ func (c *OSCommand) OpenLink(link string) error {
// EditFile opens a file in a subprocess using whatever editor is available,
// falling back to core.editor, VISUAL, EDITOR, then vi
func (c *OSCommand) EditFile(filename string) (*exec.Cmd, error) {
editor := c.getenv("VISUAL")
editor, _ := c.getGlobalGitConfig("core.editor")
if editor == "" {
editor = c.getenv("VISUAL")
}
if editor == "" {
editor = c.getenv("EDITOR")
}
@ -190,31 +199,25 @@ func (c *OSCommand) EditFile(filename string) (*exec.Cmd, error) {
}
}
if editor == "" {
return nil, errors.New("No editor defined in $VISUAL or $EDITOR")
return nil, errors.New("No editor defined in $VISUAL, $EDITOR, or git config")
}
return c.NewCmd(editor, filename), nil
return c.PrepareSubProcess(editor, filename), nil
}
// PrepareSubProcess iniPrepareSubProcessrocess then tells the Gui to switch to it
func (c *OSCommand) PrepareSubProcess(cmdName string, commandArgs ...string) *exec.Cmd {
return c.command(cmdName, commandArgs...)
}
// Quote wraps a message in platform-specific quotation marks
func (c *OSCommand) Quote(message string) string {
var quote string
if c.Platform.os == "windows" {
quote = `\"`
message = strings.NewReplacer(
`"`, `"'"'"`,
`\"`, `\\"`,
).Replace(message)
} else {
quote = `"`
message = strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
`$`, `\$`,
"`", "\\`",
).Replace(message)
message = strings.Replace(message, "`", "\\`", -1)
escapedQuote := c.Platform.escapedQuote
if strings.Contains(message, c.Platform.escapedQuote) {
escapedQuote = c.Platform.fallbackEscapedQuote
}
return quote + message + quote
return escapedQuote + message + escapedQuote
}
// Unquote removes wrapping quotations marks if they are present
@ -225,7 +228,7 @@ func (c *OSCommand) Unquote(message string) string {
// AppendLineToFile adds a new line in file
func (c *OSCommand) AppendLineToFile(filename, line string) error {
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return WrapError(err)
}
@ -240,7 +243,7 @@ func (c *OSCommand) AppendLineToFile(filename, line string) error {
// CreateTempFile writes a string to a new temp file and returns the file's name
func (c *OSCommand) CreateTempFile(filename, content string) (string, error) {
tmpfile, err := os.CreateTemp("", filename)
tmpfile, err := ioutil.TempFile("", filename)
if err != nil {
c.Log.Error(err)
return "", WrapError(err)
@ -293,7 +296,7 @@ func (c *OSCommand) RunPreparedCommand(cmd *exec.Cmd) error {
// GetLazydockerPath returns the path of the currently executed file
func (c *OSCommand) GetLazydockerPath() string {
ex, err := os.Executable() // get the executable path for docker to use
ex, err := os.Executable() // get the executable path for git to use
if err != nil {
ex = os.Args[0] // fallback to the first call argument if needed
}
@ -302,11 +305,12 @@ func (c *OSCommand) GetLazydockerPath() string {
// RunCustomCommand returns the pointer to a custom command
func (c *OSCommand) RunCustomCommand(command string) *exec.Cmd {
return c.NewCmd(c.Platform.shell, c.Platform.shellArg, command)
return c.PrepareSubProcess(c.Platform.shell, c.Platform.shellArg, command)
}
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
func (c *OSCommand) PipeCommands(commandStrings ...string) error {
cmds := make([]*exec.Cmd, len(commandStrings))
for i, str := range commandStrings {
@ -342,7 +346,7 @@ func (c *OSCommand) PipeCommands(commandStrings ...string) error {
c.Log.Error(err)
}
if b, err := io.ReadAll(stderr); err == nil {
if b, err := ioutil.ReadAll(stderr); err == nil {
if len(b) > 0 {
finalErrors = append(finalErrors, string(b))
}
@ -366,10 +370,17 @@ func (c *OSCommand) PipeCommands(commandStrings ...string) error {
// Kill kills a process. If the process has Setpgid == true, then we have anticipated that it might spawn its own child processes, so we've given it a process group ID (PGID) equal to its process id (PID) and given its child processes will inherit the PGID, we can kill that group, rather than killing the process itself.
func (c *OSCommand) Kill(cmd *exec.Cmd) error {
return kill.Kill(cmd)
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid == true {
// minus sign means we're talking about a PGID as opposed to a PID
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return cmd.Process.Kill()
}
// PrepareForChildren sets Setpgid to true on the cmd, so that when we run it as a subprocess, we can kill its group rather than the process itself. This is because some commands, like `docker-compose logs` spawn multiple children processes, and killing the parent process isn't sufficient for killing those child processes. We set the group id here, and then in subprocess.go we check if the group id is set and if so, we kill the whole group rather than just the one process.
// PrepareForChildren sets Setpgid to true on the cmd, so that when we run it as a sideproject, we can kill its group rather than the process itself. This is because some commands, like `docker-compose logs` spawn multiple children processes, and killing the parent process isn't sufficient for killing those child processes. We set the group id here, and then in subprocess.go we check if the group id is set and if so, we kill the whole group rather than just the one process.
func (c *OSCommand) PrepareForChildren(cmd *exec.Cmd) {
kill.PrepareForChildren(cmd)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
}

View file

@ -1,4 +1,3 @@
//go:build !windows
// +build !windows
package commands
@ -9,10 +8,12 @@ import (
func getPlatform() *Platform {
return &Platform{
os: runtime.GOOS,
shell: "bash",
shellArg: "-c",
openCommand: "open {{filename}}",
openLinkCommand: "open {{link}}",
os: runtime.GOOS,
shell: "bash",
shellArg: "-c",
escapedQuote: "'",
openCommand: "open {{filename}}",
openLinkCommand: "open {{link}}",
fallbackEscapedQuote: "\"",
}
}

View file

@ -1,7 +1,7 @@
package commands
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"testing"
@ -61,10 +61,11 @@ func TestOSCommandRunCommand(t *testing.T) {
// TestOSCommandEditFile is a function.
func TestOSCommandEditFile(t *testing.T) {
type scenario struct {
filename string
command func(string, ...string) *exec.Cmd
getenv func(string) string
test func(*exec.Cmd, error)
filename string
command func(string, ...string) *exec.Cmd
getenv func(string) string
getGlobalGitConfig func(string) (string, error)
test func(*exec.Cmd, error)
}
scenarios := []scenario{
@ -76,8 +77,11 @@ func TestOSCommandEditFile(t *testing.T) {
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmd *exec.Cmd, err error) {
assert.EqualError(t, err, "No editor defined in $VISUAL or $EDITOR")
assert.EqualError(t, err, "No editor defined in $VISUAL, $EDITOR, or git config")
},
},
{
@ -89,7 +93,28 @@ func TestOSCommandEditFile(t *testing.T) {
assert.EqualValues(t, "nano", name)
return exec.Command("exit", "0")
return nil
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "nano", nil
},
func(cmd *exec.Cmd, err error) {
assert.NoError(t, err)
},
},
{
"test",
func(name string, arg ...string) *exec.Cmd {
if name == "which" {
return exec.Command("exit", "1")
}
assert.EqualValues(t, "nano", name)
return nil
},
func(env string) string {
if env == "VISUAL" {
@ -98,6 +123,9 @@ func TestOSCommandEditFile(t *testing.T) {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmd *exec.Cmd, err error) {
assert.NoError(t, err)
},
@ -111,7 +139,7 @@ func TestOSCommandEditFile(t *testing.T) {
assert.EqualValues(t, "emacs", name)
return exec.Command("exit", "0")
return nil
},
func(env string) string {
if env == "EDITOR" {
@ -120,6 +148,9 @@ func TestOSCommandEditFile(t *testing.T) {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmd *exec.Cmd, err error) {
assert.NoError(t, err)
},
@ -133,11 +164,14 @@ func TestOSCommandEditFile(t *testing.T) {
assert.EqualValues(t, "vi", name)
return exec.Command("exit", "0")
return nil
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmd *exec.Cmd, err error) {
assert.NoError(t, err)
},
@ -147,20 +181,20 @@ func TestOSCommandEditFile(t *testing.T) {
for _, s := range scenarios {
OSCmd := NewDummyOSCommand()
OSCmd.command = s.command
OSCmd.getGlobalGitConfig = s.getGlobalGitConfig
OSCmd.getenv = s.getenv
s.test(OSCmd.EditFile(s.filename))
}
}
// TestOSCommandQuote is a function.
func TestOSCommandQuote(t *testing.T) {
osCommand := NewDummyOSCommand()
osCommand.Platform.os = "linux"
actual := osCommand.Quote("hello `test`")
expected := "\"hello \\`test\\`\""
expected := osCommand.Platform.escapedQuote + "hello \\`test\\`" + osCommand.Platform.escapedQuote
assert.EqualValues(t, expected, actual)
}
@ -173,7 +207,7 @@ func TestOSCommandQuoteSingleQuote(t *testing.T) {
actual := osCommand.Quote("hello 'test'")
expected := `"hello 'test'"`
expected := osCommand.Platform.fallbackEscapedQuote + "hello 'test'" + osCommand.Platform.fallbackEscapedQuote
assert.EqualValues(t, expected, actual)
}
@ -186,20 +220,7 @@ func TestOSCommandQuoteDoubleQuote(t *testing.T) {
actual := osCommand.Quote(`hello "test"`)
expected := `"hello \"test\""`
assert.EqualValues(t, expected, actual)
}
// TestOSCommandQuoteWindows tests the quote function for Windows
func TestOSCommandQuoteWindows(t *testing.T) {
osCommand := NewDummyOSCommand()
osCommand.Platform.os = "windows"
actual := osCommand.Quote(`hello "test" 'test2'`)
expected := `\"hello "'"'"test"'"'" 'test2'\"`
expected := osCommand.Platform.escapedQuote + "hello \"test\"" + osCommand.Platform.escapedQuote
assert.EqualValues(t, expected, actual)
}
@ -249,7 +270,7 @@ func TestOSCommandFileType(t *testing.T) {
{
"testDirectory",
func() {
if err := os.Mkdir("testDirectory", 0o644); err != nil {
if err := os.Mkdir("testDirectory", 0644); err != nil {
panic(err)
}
},
@ -289,7 +310,7 @@ func TestOSCommandCreateTempFile(t *testing.T) {
func(path string, err error) {
assert.NoError(t, err)
content, err := os.ReadFile(path)
content, err := ioutil.ReadFile(path)
assert.NoError(t, err)
assert.Equal(t, "content", string(content))
@ -303,53 +324,3 @@ func TestOSCommandCreateTempFile(t *testing.T) {
})
}
}
func TestOSCommandExecutableFromStringWithShellLinux(t *testing.T) {
osCommand := NewDummyOSCommand()
osCommand.Platform.os = "linux"
tests := []struct {
name string
commandStr string
want string
}{
{
"success",
"pwd",
fmt.Sprintf("%v %v %v", osCommand.Platform.shell, osCommand.Platform.shellArg, "\"pwd\""),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := osCommand.NewCommandStringWithShell(tt.commandStr)
assert.Equal(t, tt.want, got)
})
}
}
func TestOSCommandNewCommandStringWithShellWindows(t *testing.T) {
osCommand := NewDummyOSCommand()
osCommand.Platform.os = "windows"
tests := []struct {
name string
commandStr string
want string
}{
{
"success",
"pwd",
fmt.Sprintf("%v %v %v", osCommand.Platform.shell, osCommand.Platform.shellArg, "pwd"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := osCommand.NewCommandStringWithShell(tt.commandStr)
assert.Equal(t, tt.want, got)
})
}
}

View file

@ -2,8 +2,10 @@ package commands
func getPlatform() *Platform {
return &Platform{
os: "windows",
shell: "cmd",
shellArg: "/c",
os: "windows",
shell: "cmd",
shellArg: "/c",
escapedQuote: `\"`,
fallbackEscapedQuote: "\\'",
}
}

View file

@ -1,5 +0,0 @@
package commands
type Project struct {
Name string
}

View file

@ -1,10 +1,10 @@
package commands
import (
"context"
"os/exec"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types"
"github.com/fatih/color"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/sirupsen/logrus"
)
@ -13,41 +13,43 @@ import (
type Service struct {
Name string
ID string
ProjectName string
OSCommand *OSCommand
Log *logrus.Entry
Container *Container
DockerCommand LimitedDockerCommand
}
// GetDisplayStrings returns the dispaly string of Container
func (s *Service) GetDisplayStrings(isFocused bool) []string {
if s.Container == nil {
return []string{utils.ColoredString("none", color.FgBlack), utils.ColoredString(s.Name, color.FgWhite), ""}
}
cont := s.Container
return []string{cont.GetDisplayStatus(), utils.ColoredString(s.Name, color.FgWhite), cont.GetDisplayCPUPerc()}
}
// Remove removes the service's containers
func (s *Service) Remove(options container.RemoveOptions) error {
func (s *Service) Remove(options types.ContainerRemoveOptions) error {
return s.Container.Remove(options)
}
// Stop stops the service's containers
func (s *Service) Stop() error {
return s.runCommand(s.OSCommand.Config.UserConfig.CommandTemplates.StopService)
}
// Up up's the service
func (s *Service) Up() error {
return s.runCommand(s.OSCommand.Config.UserConfig.CommandTemplates.UpService)
templateString := s.OSCommand.Config.UserConfig.CommandTemplates.StopService
command := utils.ApplyTemplate(
templateString,
s.DockerCommand.NewCommandObject(CommandObject{Service: s}),
)
return s.OSCommand.RunCommand(command)
}
// Restart restarts the service
func (s *Service) Restart() error {
return s.runCommand(s.OSCommand.Config.UserConfig.CommandTemplates.RestartService)
}
// Start starts the service
func (s *Service) Start() error {
return s.runCommand(s.OSCommand.Config.UserConfig.CommandTemplates.StartService)
}
func (s *Service) runCommand(templateCmdStr string) error {
templateString := s.OSCommand.Config.UserConfig.CommandTemplates.RestartService
command := utils.ApplyTemplate(
templateCmdStr,
templateString,
s.DockerCommand.NewCommandObject(CommandObject{Service: s}),
)
return s.OSCommand.RunCommand(command)
@ -58,6 +60,11 @@ func (s *Service) Attach() (*exec.Cmd, error) {
return s.Container.Attach()
}
// Top returns process information
func (s *Service) Top() (types.ContainerProcessList, error) {
return s.Container.Top()
}
// ViewLogs attaches to a subprocess viewing the service's logs
func (s *Service) ViewLogs() (*exec.Cmd, error) {
templateString := s.OSCommand.Config.UserConfig.CommandTemplates.ViewServiceLogs
@ -73,12 +80,12 @@ func (s *Service) ViewLogs() (*exec.Cmd, error) {
}
// RenderTop renders the process list of the service
func (s *Service) RenderTop(ctx context.Context) (string, error) {
func (s *Service) RenderTop() (string, error) {
templateString := s.OSCommand.Config.UserConfig.CommandTemplates.ServiceTop
command := utils.ApplyTemplate(
templateString,
s.DockerCommand.NewCommandObject(CommandObject{Service: s}),
)
return s.OSCommand.RunCommandWithOutputContext(ctx, command)
return s.OSCommand.RunCommandWithOutput(command)
}

View file

@ -1,159 +0,0 @@
package ssh
import (
"context"
"fmt"
"io"
"net"
"net/url"
"os"
"os/exec"
"path"
"time"
)
// we only need these two methods from our OSCommand struct, for killing commands
type CmdKiller interface {
Kill(cmd *exec.Cmd) error
PrepareForChildren(cmd *exec.Cmd)
}
type SSHHandler struct {
oSCommand CmdKiller
dialContext func(ctx context.Context, network, addr string) (io.Closer, error)
startCmd func(*exec.Cmd) error
tempDir func(dir string, pattern string) (name string, err error)
getenv func(key string) string
setenv func(key, value string) error
}
func NewSSHHandler(oSCommand CmdKiller) *SSHHandler {
return &SSHHandler{
oSCommand: oSCommand,
dialContext: func(ctx context.Context, network, addr string) (io.Closer, error) {
return (&net.Dialer{}).DialContext(ctx, network, addr)
},
startCmd: func(cmd *exec.Cmd) error { return cmd.Start() },
tempDir: os.MkdirTemp,
getenv: os.Getenv,
setenv: os.Setenv,
}
}
// HandleSSHDockerHost overrides the DOCKER_HOST environment variable
// to point towards a local unix socket tunneled over SSH to the specified ssh host.
func (self *SSHHandler) HandleSSHDockerHost() (io.Closer, error) {
const key = "DOCKER_HOST"
ctx := context.Background()
u, err := url.Parse(self.getenv(key))
if err != nil {
// if no or an invalid docker host is specified, continue nominally
return noopCloser{}, nil
}
// if the docker host scheme is "ssh", forward the docker socket before creating the client
if u.Scheme == "ssh" {
tunnel, err := self.createDockerHostTunnel(ctx, u.Host)
if err != nil {
return noopCloser{}, fmt.Errorf("tunnel ssh docker host: %w", err)
}
err = self.setenv(key, tunnel.socketPath)
if err != nil {
return noopCloser{}, fmt.Errorf("override DOCKER_HOST to tunneled socket: %w", err)
}
return tunnel, nil
}
return noopCloser{}, nil
}
type noopCloser struct{}
func (noopCloser) Close() error { return nil }
type tunneledDockerHost struct {
socketPath string
cmd *exec.Cmd
oSCommand CmdKiller
}
var _ io.Closer = (*tunneledDockerHost)(nil)
func (t *tunneledDockerHost) Close() error {
return t.oSCommand.Kill(t.cmd)
}
func (self *SSHHandler) createDockerHostTunnel(ctx context.Context, remoteHost string) (*tunneledDockerHost, error) {
socketDir, err := self.tempDir("/tmp", "lazydocker-sshtunnel-")
if err != nil {
return nil, fmt.Errorf("create ssh tunnel tmp file: %w", err)
}
localSocket := path.Join(socketDir, "dockerhost.sock")
cmd, err := self.tunnelSSH(ctx, remoteHost, localSocket)
if err != nil {
return nil, fmt.Errorf("tunnel docker host over ssh: %w", err)
}
// set a reasonable timeout, then wait for the socket to dial successfully
// before attempting to create a new docker client
const socketTunnelTimeout = 8 * time.Second
ctx, cancel := context.WithTimeout(ctx, socketTunnelTimeout)
defer cancel()
err = self.retrySocketDial(ctx, localSocket)
if err != nil {
return nil, fmt.Errorf("ssh tunneled socket never became available: %w", err)
}
// construct the new DOCKER_HOST url with the proper scheme
newDockerHostURL := url.URL{Scheme: "unix", Path: localSocket}
return &tunneledDockerHost{
socketPath: newDockerHostURL.String(),
cmd: cmd,
oSCommand: self.oSCommand,
}, nil
}
// Attempt to dial the socket until it becomes available.
// The retry loop will continue until the parent context is canceled.
func (self *SSHHandler) retrySocketDial(ctx context.Context, socketPath string) error {
t := time.NewTicker(1 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
// attempt to dial the socket, exit on success
err := self.tryDial(ctx, socketPath)
if err != nil {
continue
}
return nil
}
}
// Try to dial the specified unix socket, immediately close the connection if successfully created.
func (self *SSHHandler) tryDial(ctx context.Context, socketPath string) error {
conn, err := self.dialContext(ctx, "unix", socketPath)
if err != nil {
return err
}
defer conn.Close()
return nil
}
func (self *SSHHandler) tunnelSSH(ctx context.Context, host, localSocket string) (*exec.Cmd, error) {
cmd := exec.CommandContext(ctx, "ssh", "-L", localSocket+":/var/run/docker.sock", host, "-N")
self.oSCommand.PrepareForChildren(cmd)
err := self.startCmd(cmd)
if err != nil {
return nil, err
}
return cmd, nil
}

View file

@ -1,109 +0,0 @@
package ssh
import (
"context"
"io"
"os/exec"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSSHHandlerHandleSSHDockerHost(t *testing.T) {
type scenario struct {
testName string
envVarValue string
expectedDialContextCount int
expectedStartCmdCount int
}
scenarios := []scenario{
{
testName: "No env var set",
envVarValue: "",
expectedDialContextCount: 0,
expectedStartCmdCount: 0,
},
{
testName: "Env var set with https scheme",
envVarValue: "https://myhost.com",
expectedStartCmdCount: 0,
expectedDialContextCount: 0,
},
{
testName: "Env var set with ssh scheme",
envVarValue: "ssh://myhost@192.168.5.178",
expectedStartCmdCount: 1,
expectedDialContextCount: 1,
},
}
for _, s := range scenarios {
s := s
t.Run(s.testName, func(t *testing.T) {
getenv := func(key string) string {
if key != "DOCKER_HOST" {
t.Errorf("Expected key to be DOCKER_HOST, got %s", key)
}
return s.envVarValue
}
tempDir := func(dir string, pattern string) (string, error) {
assert.Equal(t, "/tmp", dir)
assert.Equal(t, "lazydocker-sshtunnel-", pattern)
return "/tmp/lazydocker-ssh-tunnel-12345", nil
}
setenv := func(key, value string) error {
assert.Equal(t, "DOCKER_HOST", key)
assert.Equal(t, "unix:///tmp/lazydocker-ssh-tunnel-12345/dockerhost.sock", value)
return nil
}
startCmdCount := 0
startCmd := func(cmd *exec.Cmd) error {
assert.EqualValues(t, []string{"ssh", "-L", "/tmp/lazydocker-ssh-tunnel-12345/dockerhost.sock:/var/run/docker.sock", "192.168.5.178", "-N"}, cmd.Args)
startCmdCount++
return nil
}
dialContextCount := 0
dialContext := func(ctx context.Context, network string, address string) (io.Closer, error) {
assert.Equal(t, "unix", network)
assert.Equal(t, "/tmp/lazydocker-ssh-tunnel-12345/dockerhost.sock", address)
dialContextCount++
return noopCloser{}, nil
}
handler := &SSHHandler{
oSCommand: &fakeCmdKiller{},
dialContext: dialContext,
startCmd: startCmd,
tempDir: tempDir,
getenv: getenv,
setenv: setenv,
}
_, err := handler.HandleSSHDockerHost()
assert.NoError(t, err)
assert.Equal(t, s.expectedDialContextCount, dialContextCount)
assert.Equal(t, s.expectedStartCmdCount, startCmdCount)
})
}
}
type fakeCmdKiller struct{}
func (self *fakeCmdKiller) Kill(cmd *exec.Cmd) error {
return nil
}
func (self *fakeCmdKiller) PrepareForChildren(cmd *exec.Cmd) {}

View file

@ -2,9 +2,10 @@ package commands
import (
"context"
"sort"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
@ -12,28 +13,37 @@ import (
// Volume : A docker Volume
type Volume struct {
Name string
Volume *volume.Volume
Volume *types.Volume
Client *client.Client
OSCommand *OSCommand
Log *logrus.Entry
DockerCommand LimitedDockerCommand
}
// GetDisplayStrings returns the dispaly string of Container
func (v *Volume) GetDisplayStrings(isFocused bool) []string {
return []string{v.Volume.Driver, v.Name}
}
// RefreshVolumes gets the volumes and stores them
func (c *DockerCommand) RefreshVolumes() ([]*Volume, error) {
result, err := c.Client.VolumeList(context.Background(), volume.ListOptions{})
func (c *DockerCommand) RefreshVolumes() error {
result, err := c.Client.VolumeList(context.Background(), filters.Args{})
if err != nil {
return nil, err
return err
}
volumes := result.Volumes
ownVolumes := make([]*Volume, len(volumes))
for i, vol := range volumes {
sort.Slice(volumes, func(i, j int) bool {
return volumes[i].Name < volumes[j].Name
})
for i, volume := range volumes {
ownVolumes[i] = &Volume{
Name: vol.Name,
Volume: vol,
Name: volume.Name,
Volume: volume,
Client: c.Client,
OSCommand: c.OSCommand,
Log: c.Log,
@ -41,7 +51,9 @@ func (c *DockerCommand) RefreshVolumes() ([]*Volume, error) {
}
}
return ownVolumes, nil
c.Volumes = ownVolumes
return nil
}
// PruneVolumes prunes volumes

View file

@ -1,498 +1,28 @@
// Package config handles all the user-configuration. The fields here are
// all in PascalCase but in your actual config.yml they'll be in camelCase.
// You can view the default config with `lazydocker --config`.
// You can open your config file by going to the status panel (using left-arrow)
// and pressing 'o'.
// You can directly edit the file (e.g. in vim) by pressing 'e' instead.
// To see the final config after your user-specific options have been merged
// with the defaults, go to the 'about' tab in the status panel.
// Because of the way we merge your user config with the defaults you may need
// to be careful: if for example you set a `commandTemplates:` yaml key but then
// give it no child values, it will scrap all of the defaults and the app will
// probably crash.
package config
import (
"os"
"io/ioutil"
"path/filepath"
"strings"
"time"
"github.com/OpenPeeDeeP/xdg"
"github.com/jesseduffield/yaml"
"github.com/shibukawa/configdir"
yaml "gopkg.in/yaml.v2"
)
// UserConfig holds all of the user-configurable options
type UserConfig struct {
// Gui is for configuring visual things like colors and whether we show or
// hide things
Gui GuiConfig `yaml:"gui,omitempty"`
// ConfirmOnQuit when enabled prompts you to confirm you want to quit when you
// hit esc or q when no confirmation panels are open
ConfirmOnQuit bool `yaml:"confirmOnQuit,omitempty"`
// Logs determines how we render/filter a container's logs
Logs LogsConfig `yaml:"logs,omitempty"`
// CommandTemplates determines what commands actually get called when we run
// certain commands
CommandTemplates CommandTemplatesConfig `yaml:"commandTemplates,omitempty"`
// CustomCommands determines what shows up in your custom commands menu when
// you press 'c'. You can use go templates to access three items on the
// struct: the DockerCompose command (defaulted to 'docker-compose'), the
// Service if present, and the Container if present. The struct types for
// those are found in the commands package
CustomCommands CustomCommands `yaml:"customCommands,omitempty"`
// BulkCommands are commands that apply to all items in a panel e.g.
// killing all containers, stopping all services, or pruning all images
BulkCommands CustomCommands `yaml:"bulkCommands,omitempty"`
// OS determines what defaults are set for opening files and links
OS OSConfig `yaml:"oS,omitempty"`
// Stats determines how long lazydocker will gather container stats for, and
// what stat info to graph
Stats StatsConfig `yaml:"stats,omitempty"`
// Replacements determines how we render an item's info
Replacements Replacements `yaml:"replacements,omitempty"`
// For demo purposes: any list item with one of these strings as a substring
// will be filtered out and not displayed.
// Not documented because it's subject to change
Ignore []string `yaml:"ignore,omitempty"`
}
// ThemeConfig is for setting the colors of panels and some text.
type ThemeConfig struct {
ActiveBorderColor []string `yaml:"activeBorderColor,omitempty"`
InactiveBorderColor []string `yaml:"inactiveBorderColor,omitempty"`
SelectedLineBgColor []string `yaml:"selectedLineBgColor,omitempty"`
OptionsTextColor []string `yaml:"optionsTextColor,omitempty"`
}
// GuiConfig is for configuring visual things like colors and whether we show or
// hide things
type GuiConfig struct {
// ScrollHeight determines how many characters you scroll at a time when
// scrolling the main panel
ScrollHeight int `yaml:"scrollHeight,omitempty"`
// Language determines which language the GUI displayed.
Language string `yaml:"language,omitempty"`
// ScrollPastBottom determines whether you can scroll past the bottom of the
// main view
ScrollPastBottom bool `yaml:"scrollPastBottom,omitempty"`
// IgnoreMouseEvents is for when you do not want to use your mouse to interact
// with anything
IgnoreMouseEvents bool `yaml:"mouseEvents,omitempty"`
// Theme determines what colors and color attributes your panel borders have.
// I always set inactiveBorderColor to black because in my terminal it's more
// of a grey, but that doesn't work in your average terminal. I highly
// recommended finding a combination that works for you
Theme ThemeConfig `yaml:"theme,omitempty"`
// ShowAllContainers determines whether the Containers panel contains all the
// containers returned by `docker ps -a`, or just those containers that aren't
// directly linked to a service. It is probably desirable to enable this if
// you have multiple containers per service, but otherwise it can cause a lot
// of clutter
ShowAllContainers bool `yaml:"showAllContainers,omitempty"`
// ReturnImmediately determines whether you get the 'press enter to return to
// lazydocker' message after a subprocess has completed. You would set this to
// true if you often want to see the output of subprocesses before returning
// to lazydocker. I would default this to false but then people who want it
// set to true won't even know the config option exists.
ReturnImmediately bool `yaml:"returnImmediately,omitempty"`
// WrapMainPanel determines whether we use word wrap on the main panel
WrapMainPanel bool `yaml:"wrapMainPanel,omitempty"`
// LegacySortContainers determines if containers should be sorted using legacy approach.
// By default, containers are now sorted by status. This setting allows users to
// use legacy behaviour instead.
LegacySortContainers bool `yaml:"legacySortContainers,omitempty"`
// If 0.333, then the side panels will be 1/3 of the screen's width
SidePanelWidth float64 `yaml:"sidePanelWidth"`
// Determines whether we show the bottom line (the one containing keybinding
// info and the status of the app).
ShowBottomLine bool `yaml:"showBottomLine"`
// When true, increases vertical space used by focused side panel,
// creating an accordion effect
ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"`
// ScreenMode allow user to specify which screen mode will be used on startup
ScreenMode string `yaml:"screenMode,omitempty"`
// Determines the style of the container status and container health display in the
// containers panel. "long": full words (default), "short": one or two characters,
// "icon": unicode emoji.
ContainerStatusHealthStyle string `yaml:"containerStatusHealthStyle"`
// Window border style.
// One of 'rounded' (default) | 'single' | 'double' | 'hidden'
Border string `yaml:"border"`
}
// CommandTemplatesConfig determines what commands actually get called when we
// run certain commands
type CommandTemplatesConfig struct {
// RestartService is for restarting a service. docker-compose restart {{
// .Service.Name }} works but I prefer docker-compose up --force-recreate {{
// .Service.Name }}
RestartService string `yaml:"restartService,omitempty"`
// StartService is just like the above but for starting
StartService string `yaml:"startService,omitempty"`
// UpService ups the service (creates and starts)
UpService string `yaml:"upService,omitempty"`
// Runs "docker-compose up -d"
Up string `yaml:"up,omitempty"`
// downs everything
Down string `yaml:"down,omitempty"`
// downs and removes volumes
DownWithVolumes string `yaml:"downWithVolumes,omitempty"`
// DockerCompose is for your docker-compose command. You may want to combine a
// few different docker-compose.yml files together, in which case you can set
// this to "docker compose -f foo/docker-compose.yml -f
// bah/docker-compose.yml". The reason that the other docker-compose command
// templates all start with {{ .DockerCompose }} is so that they can make use
// of whatever you've set in this value rather than you having to copy and
// paste it to all the other commands
DockerCompose string `yaml:"dockerCompose,omitempty"`
// StopService is the command for stopping a service
StopService string `yaml:"stopService,omitempty"`
// ServiceLogs get the logs for a service. This is actually not currently
// used; we just get the logs of the corresponding container. But we should
// probably support explicitly returning the logs of the service when you've
// selected the service, given that a service may have multiple containers.
ServiceLogs string `yaml:"serviceLogs,omitempty"`
// ViewServiceLogs is for when you want to view the logs of a service as a
// subprocess. This defaults to having no filter, unlike the in-app logs
// commands which will usually filter down to the last hour for the sake of
// performance.
ViewServiceLogs string `yaml:"viewServiceLogs,omitempty"`
// RebuildService is the command for rebuilding a service. Defaults to
// something along the lines of `{{ .DockerCompose }} up --build {{
// .Service.Name }}`
RebuildService string `yaml:"rebuildService,omitempty"`
// RecreateService is for force-recreating a service. I prefer this to
// restarting a service because it will also restart any dependent services
// and ensure they're running before trying to run the service at hand
RecreateService string `yaml:"recreateService,omitempty"`
// AllLogs is for showing what you get from doing `docker compose logs`. It
// combines all the logs together
AllLogs string `yaml:"allLogs,omitempty"`
// ViewAllLogs is the command we use when you want to see all logs in a subprocess with no filtering
ViewAllLogs string `yaml:"viewAlLogs,omitempty"`
// DockerComposeConfig is the command for viewing the config of your docker
// compose. It basically prints out the yaml from your docker-compose.yml
// file(s)
DockerComposeConfig string `yaml:"dockerComposeConfig,omitempty"`
// CheckDockerComposeConfig is what we use to check whether we are in a
// docker-compose context. If the command returns an error then we clearly
// aren't in a docker-compose config and we then just hide the services panel
// and only show containers
CheckDockerComposeConfig string `yaml:"checkDockerComposeConfig,omitempty"`
// ServiceTop is the command for viewing the processes under a given service
ServiceTop string `yaml:"serviceTop,omitempty"`
}
// OSConfig contains config on the level of the os
type OSConfig struct {
// OpenCommand is the command for opening a file
OpenCommand string `yaml:"openCommand,omitempty"`
// OpenCommand is the command for opening a link
OpenLinkCommand string `yaml:"openLinkCommand,omitempty"`
}
// GraphConfig specifies how to make a graph of recorded container stats
type GraphConfig struct {
// Min sets the minimum value that you want to display. If you want to set
// this, you should also set MinType to "static". The reason for this is that
// if Min == 0, it's not clear if it has not been set (given that the
// zero-value of an int is 0) or if it's intentionally been set to 0.
Min float64 `yaml:"min,omitempty"`
// Max sets the maximum value that you want to display. If you want to set
// this, you should also set MaxType to "static". The reason for this is that
// if Max == 0, it's not clear if it has not been set (given that the
// zero-value of an int is 0) or if it's intentionally been set to 0.
Max float64 `yaml:"max,omitempty"`
// Height sets the height of the graph in ascii characters
Height int `yaml:"height,omitempty"`
// Caption sets the caption of the graph. If you want to show CPU Percentage
// you could set this to "CPU (%)"
Caption string `yaml:"caption,omitempty"`
// This is the path to the stat that you want to display. It is based on the
// RecordedStats struct in container_stats.go, so feel free to look there to
// see all the options available. Alternatively if you go into lazydocker and
// go to the stats tab, you'll see that same struct in JSON format, so you can
// just PascalCase the path and you'll have a valid path. E.g.
// ClientStats.blkio_stats -> "ClientStats.BlkioStats"
StatPath string `yaml:"statPath,omitempty"`
// This determines the color of the graph. This can be any color attribute,
// e.g. 'blue', 'green'
Color string `yaml:"color,omitempty"`
// MinType and MaxType are each one of "", "static". blank means the min/max
// of the data set will be used. "static" means the min/max specified will be
// used
MinType string `yaml:"minType,omitempty"`
// MaxType is just like MinType but for the max value
MaxType string `yaml:"maxType,omitempty"`
}
// StatsConfig contains the stuff relating to stats and graphs
type StatsConfig struct {
// Graphs contains the configuration for the stats graphs we want to show in
// the app
Graphs []GraphConfig
// MaxDuration tells us how long to collect stats for. Currently this defaults
// to "5m" i.e. 5 minutes.
MaxDuration time.Duration `yaml:"maxDuration,omitempty"`
}
// CustomCommands contains the custom commands that you might want to use on any
// given service or container
type CustomCommands struct {
// Containers contains the custom commands for containers
Containers []CustomCommand `yaml:"containers,omitempty"`
// Services contains the custom commands for services
Services []CustomCommand `yaml:"services,omitempty"`
// Images contains the custom commands for images
Images []CustomCommand `yaml:"images,omitempty"`
// Volumes contains the custom commands for volumes
Volumes []CustomCommand `yaml:"volumes,omitempty"`
// Networks contains the custom commands for networks
Networks []CustomCommand `yaml:"networks,omitempty"`
}
// Replacements contains the stuff relating to rendering a container's info
type Replacements struct {
// ImageNamePrefixes tells us how to replace a prefix in the Docker image name
ImageNamePrefixes map[string]string `yaml:"imageNamePrefixes,omitempty"`
}
// CustomCommand is a template for a command we want to run against a service or
// container
type CustomCommand struct {
// Name is the name of the command, purely for visual display
Name string `yaml:"name"`
// Attach tells us whether to switch to a subprocess to interact with the
// called program, or just read its output. If Attach is set to false, the
// command will run in the background. I'm open to the idea of having a third
// option where the output plays in the main panel.
Attach bool `yaml:"attach"`
// Shell indicates whether to invoke the Command on a shell or not.
// Example of a bash invoked command: `/bin/bash -c "{Command}".
Shell bool `yaml:"shell"`
// Command is the command we want to run. We can use the go templates here as
// well. One example might be `{{ .DockerCompose }} exec {{ .Service.Name }}
// /bin/sh`
Command string `yaml:"command"`
// ServiceNames is used to restrict this command to just one or more services.
// An example might be 'rails migrate' for your rails api service(s). This
// field has no effect on customcommands under the 'communications' part of
// the customCommand config.
ServiceNames []string `yaml:"serviceNames"`
// InternalFunction is the name of a function inside lazydocker that we want to run, as opposed to a command-line command. This is only used internally and can't be configured by the user
InternalFunction func() error `yaml:"-"`
}
type LogsConfig struct {
Timestamps bool `yaml:"timestamps,omitempty"`
Since string `yaml:"since,omitempty"`
Tail string `yaml:"tail,omitempty"`
}
// GetDefaultConfig returns the application default configuration NOTE (to
// contributors, not users): do not default a boolean to true, because false is
// the boolean zero value and this will be ignored when parsing the user's
// config
func GetDefaultConfig() UserConfig {
duration, err := time.ParseDuration("3m")
if err != nil {
panic(err)
}
return UserConfig{
Gui: GuiConfig{
ScrollHeight: 2,
Language: "auto",
ScrollPastBottom: false,
IgnoreMouseEvents: false,
Theme: ThemeConfig{
ActiveBorderColor: []string{"green", "bold"},
InactiveBorderColor: []string{"default"},
SelectedLineBgColor: []string{"blue"},
OptionsTextColor: []string{"blue"},
},
ShowAllContainers: false,
ReturnImmediately: false,
WrapMainPanel: true,
LegacySortContainers: false,
SidePanelWidth: 0.3333,
ShowBottomLine: true,
ExpandFocusedSidePanel: false,
ScreenMode: "normal",
ContainerStatusHealthStyle: "long",
},
ConfirmOnQuit: false,
Logs: LogsConfig{
Timestamps: false,
Since: "60m",
Tail: "",
},
CommandTemplates: CommandTemplatesConfig{
DockerCompose: "docker compose",
RestartService: "{{ .DockerCompose }} restart {{ .Service.Name }}",
StartService: "{{ .DockerCompose }} start {{ .Service.Name }}",
Up: "{{ .DockerCompose }} up -d",
Down: "{{ .DockerCompose }} down",
DownWithVolumes: "{{ .DockerCompose }} down --volumes",
UpService: "{{ .DockerCompose }} up -d {{ .Service.Name }}",
RebuildService: "{{ .DockerCompose }} up -d --build {{ .Service.Name }}",
RecreateService: "{{ .DockerCompose }} up -d --force-recreate {{ .Service.Name }}",
StopService: "{{ .DockerCompose }} stop {{ .Service.Name }}",
ServiceLogs: "{{ .DockerCompose }} logs --since=60m --follow {{ .Service.Name }}",
ViewServiceLogs: "{{ .DockerCompose }} logs --follow {{ .Service.Name }}",
AllLogs: "{{ .DockerCompose }} logs --tail=300 --follow",
ViewAllLogs: "{{ .DockerCompose }} logs",
DockerComposeConfig: "{{ .DockerCompose }} config",
CheckDockerComposeConfig: "{{ .DockerCompose }} config --quiet",
ServiceTop: "{{ .DockerCompose }} top {{ .Service.Name }}",
},
CustomCommands: CustomCommands{
Containers: []CustomCommand{},
Services: []CustomCommand{},
Images: []CustomCommand{},
Volumes: []CustomCommand{},
},
BulkCommands: CustomCommands{
Services: []CustomCommand{
{
Name: "up",
Command: "{{ .DockerCompose }} up -d",
},
{
Name: "up (attached)",
Command: "{{ .DockerCompose }} up",
Attach: true,
},
{
Name: "stop",
Command: "{{ .DockerCompose }} stop",
},
{
Name: "pull",
Command: "{{ .DockerCompose }} pull",
Attach: true,
},
{
Name: "build",
Command: "{{ .DockerCompose }} build --parallel --force-rm",
Attach: true,
},
{
Name: "down",
Command: "{{ .DockerCompose }} down",
},
{
Name: "down with volumes",
Command: "{{ .DockerCompose }} down --volumes",
},
{
Name: "down with images",
Command: "{{ .DockerCompose }} down --rmi all",
},
{
Name: "down with volumes and images",
Command: "{{ .DockerCompose }} down --volumes --rmi all",
},
},
Containers: []CustomCommand{},
Images: []CustomCommand{},
Volumes: []CustomCommand{},
},
OS: GetPlatformDefaultConfig(),
Stats: StatsConfig{
MaxDuration: duration,
Graphs: []GraphConfig{
{
Caption: "CPU (%)",
StatPath: "DerivedStats.CPUPercentage",
Color: "cyan",
},
{
Caption: "Memory (%)",
StatPath: "DerivedStats.MemoryPercentage",
Color: "green",
},
},
},
Replacements: Replacements{
ImageNamePrefixes: map[string]string{},
},
}
}
// AppConfig contains the base configuration fields required for lazydocker.
// AppConfig contains the base configuration fields required for lazygit.
type AppConfig struct {
Debug bool `long:"debug" env:"DEBUG" default:"false"`
Version string `long:"version" env:"VERSION" default:"unversioned"`
Commit string `long:"commit" env:"COMMIT"`
BuildDate string `long:"build-date" env:"BUILD_DATE"`
Name string `long:"name" env:"NAME" default:"lazydocker"`
Name string `long:"name" env:"NAME" default:"lazygit"`
BuildSource string `long:"build-source" env:"BUILD_SOURCE" default:""`
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) (*AppConfig, error) {
configDir, err := findOrCreateConfigDir(name)
if err != nil {
return nil, err
@ -503,54 +33,29 @@ func NewAppConfig(name, version, commit, date string, buildSource string, debugg
return nil, err
}
// Pass compose files as individual -f flags to docker compose
if len(composeFiles) > 0 {
userConfig.CommandTemplates.DockerCompose += " -f " + strings.Join(composeFiles, " -f ")
}
appConfig := &AppConfig{
Name: name,
Version: version,
Commit: commit,
BuildDate: date,
Debug: debuggingFlag || os.Getenv("DEBUG") == "TRUE",
Debug: true, // TODO: restore os.Getenv("DEBUG") == "TRUE"
BuildSource: buildSource,
UserConfig: userConfig,
ConfigDir: configDir,
ProjectDir: projectDir,
ProjectName: projectName,
}
return appConfig, nil
}
func configDirForVendor(vendor string, projectName string) string {
envConfigDir := os.Getenv("CONFIG_DIR")
if envConfigDir != "" {
return envConfigDir
}
configDirs := xdg.New(vendor, projectName)
return configDirs.ConfigHome()
}
func configDir(projectName string) string {
legacyConfigDirectory := configDirForVendor("jesseduffield", projectName)
if _, err := os.Stat(legacyConfigDirectory); !os.IsNotExist(err) {
return legacyConfigDirectory
}
configDirectory := configDirForVendor("", projectName)
return configDirectory
}
func findOrCreateConfigDir(projectName string) (string, error) {
folder := configDir(projectName)
configDirs := configdir.New("jesseduffield", projectName)
folders := configDirs.QueryFolders(configdir.Global)
err := os.MkdirAll(folder, 0o755)
if err != nil {
if err := folders[0].CreateParentDir("foo"); err != nil {
return "", err
}
return folder, nil
return folders[0].Path, nil
}
func loadUserConfigWithDefaults(configDir string) (*UserConfig, error) {
@ -560,21 +65,7 @@ func loadUserConfigWithDefaults(configDir string) (*UserConfig, error) {
}
func loadUserConfig(configDir string, base *UserConfig) (*UserConfig, error) {
fileName := filepath.Join(configDir, "config.yml")
if _, err := os.Stat(fileName); err != nil {
if os.IsNotExist(err) {
file, err := os.Create(fileName)
if err != nil {
return nil, err
}
file.Close()
} else {
return nil, err
}
}
content, err := os.ReadFile(fileName)
content, err := ioutil.ReadFile(filepath.Join(configDir, "config.yml"))
if err != nil {
return nil, err
}
@ -586,10 +77,160 @@ func loadUserConfig(configDir string, base *UserConfig) (*UserConfig, error) {
return base, nil
}
type UserConfig struct {
Gui GuiConfig `yaml:"gui,omitempty"`
Reporting string `yaml:"reporting,omitempty"`
ConfirmOnQuit bool `yaml:"confirmOnQuit,omitempty"`
CommandTemplates CommandTemplatesConfig `yaml:"commandTemplates,omitempty"`
CustomCommands CustomCommands `yaml:"customCommands,omitempty"`
OS OSConfig `yaml:"oS,omitempty"`
Update UpdateConfig `yaml:"update,omitempty"`
Stats StatsConfig `yaml:"stats,omitempty"`
}
type ThemeConfig struct {
ActiveBorderColor []string `yaml:"activeBorderColor,omitempty"`
InactiveBorderColor []string `yaml:"inactiveBorderColor,omitempty"`
OptionsTextColor []string `yaml:"optionsTextColor,omitempty"`
}
type GuiConfig struct {
ScrollHeight int `yaml:"scrollHeight,omitempty"`
ScrollPastBottom bool `yaml:"scrollPastBottom,omitempty"`
MouseEvents bool `yaml:"mouseEvents,omitempty"`
Theme ThemeConfig `yaml:"theme,omitempty"`
ShowAllContainers bool `yaml:"showAllContainers,omitempty"`
}
type CommandTemplatesConfig struct {
RestartService string `yaml:"restartService,omitempty"`
DockerCompose string `yaml:"dockerCompose,omitempty"`
StopService string `yaml:"stopService,omitempty"`
ServiceLogs string `yaml:"serviceLogs,omitempty"`
ViewServiceLogs string `yaml:"viewServiceLogs,omitempty"`
RebuildService string `yaml:"rebuildService,omitempty"`
// ViewContainerLogs is for viewing the container logs in a subprocess. We have this as a separate command in case you want to show all the logs rather than just tail them for the sake of reducing CPU load when in the lazydocker GUI
ViewContainerLogs string `yaml:"viewContainerLogs,omitempty"`
ContainerLogs string `yaml:"containerLogs,omitempty"`
ContainerTTYLogs string `yaml:"containerTTYLogs,omitempty"`
AllLogs string `yaml:"allLogs,omitempty"`
ViewAllLogs string `yaml:"viewAlLogs,omitempty"`
DockerComposeConfig string `yaml:"dockerComposeConfig,omitempty"`
CheckDockerComposeConfig string `yaml:"checkDockerComposeConfig,omitempty"`
ServiceTop string `yaml:"serviceTop,omitempty"`
}
type OSConfig struct {
OpenCommand string `yaml:"openCommand,omitempty"`
OpenLinkCommand string `yaml:"openLinkCommand,omitempty"`
}
type UpdateConfig struct {
Method string `yaml:"method,omitempty"`
}
// GraphConfig specifies how to make a graph of recorded container stats
type GraphConfig struct {
Min float64 `yaml:"min,omitempty"`
Max float64 `yaml:"max,omitempty"`
Height int `yaml:"height,omitempty"`
Caption string `yaml:"caption,omitempty"`
StatPath string `yaml:"statPath,omitempty"`
Color string `yaml:"color,omitempty"`
// MinType and MaxType are each one of "", "static". blank means the min/max of the data set will be used. "static" means the min/max specified will be used
MinType string `yaml:"minType,omitempty"`
MaxType string `yaml:"maxType,omitempty"`
}
type StatsConfig struct {
Graphs []GraphConfig
MaxDuration time.Duration `yaml:"maxDuration,omitempty"`
}
type CustomCommands struct {
Containers []CustomCommand `yaml:"containers,omitempty"`
Services []CustomCommand `yaml:"services,omitempty"`
}
type CustomCommand struct {
Attach bool
Command string
}
// GetDefaultConfig returns the application default configuration
// NOTE: do not default a boolean to true, because false is the boolean zero value and this will be ignored when parsing the user's config
func GetDefaultConfig() UserConfig {
return UserConfig{
Gui: GuiConfig{
ScrollHeight: 2,
ScrollPastBottom: false,
MouseEvents: false,
Theme: ThemeConfig{
ActiveBorderColor: []string{"white", "bold"},
InactiveBorderColor: []string{"white"},
OptionsTextColor: []string{"blue"},
},
ShowAllContainers: false,
},
Reporting: "undetermined",
ConfirmOnQuit: false,
CommandTemplates: CommandTemplatesConfig{
DockerCompose: "docker-compose",
RestartService: "{{ .DockerCompose }} restart {{ .Service.Name }}",
RebuildService: "{{ .DockerCompose }} up -d --build {{ .Service.Name }}",
StopService: "{{ .DockerCompose }} stop {{ .Service.Name }}",
ServiceLogs: "{{ .DockerCompose }} logs --since=60m --follow {{ .Service.Name }}",
ViewServiceLogs: "{{ .DockerCompose }} logs --follow {{ .Service.Name }}",
AllLogs: "{{ .DockerCompose }} logs --tail=300 --follow",
ViewAllLogs: "{{ .DockerCompose }} logs",
DockerComposeConfig: "{{ .DockerCompose }} config",
CheckDockerComposeConfig: "{{ .DockerCompose }} config --quiet",
ContainerLogs: "docker logs --timestamps --follow --since=60m {{ .Container.ID }}",
ViewContainerLogs: "docker logs --timestamps --follow --since=60m {{ .Container.ID }}",
ContainerTTYLogs: "docker logs --follow --since=60m {{ .Container.ID }}",
ServiceTop: "{{ .DockerCompose }} top {{ .Service.Name }}",
},
CustomCommands: CustomCommands{
Containers: []CustomCommand{
{
Attach: true,
Command: "docker exec -it {{ .Container.ID }} /bin/bash",
},
},
Services: []CustomCommand{},
},
OS: GetPlatformDefaultConfig(),
Update: UpdateConfig{
Method: "never",
},
Stats: StatsConfig{
Graphs: []GraphConfig{
{
Min: 0,
Max: 100,
Height: 10,
Caption: "CPU (%)",
StatPath: "DerivedStats.CPUPercentage",
Color: "blue",
},
{
Min: 0,
Max: 100,
Height: 10,
Caption: "Memory (%)",
StatPath: "DerivedStats.MemoryPercentage",
Color: "green",
},
},
},
}
}
// WriteToUserConfig allows you to set a value on the user config to be saved
// note that if you set a zero-value, it may be ignored e.g. a false or 0 or
// empty string this is because we are using the omitempty yaml directive so
// that we don't write a heap of zero values to the user's config.yml
// note that if you set a zero-value, it may be ignored e.g. a false or 0 or empty string
// this is because we are using the omitempty yaml directive so that we don't write a heap
// of zero values to the user's config.yml
func (c *AppConfig) WriteToUserConfig(updateConfig func(*UserConfig) error) error {
userConfig, err := loadUserConfig(c.ConfigDir, &UserConfig{})
if err != nil {
@ -600,15 +241,14 @@ func (c *AppConfig) WriteToUserConfig(updateConfig func(*UserConfig) error) erro
return err
}
file, err := os.OpenFile(c.ConfigFilename(), os.O_WRONLY|os.O_CREATE, 0o666)
out, err := yaml.Marshal(userConfig)
if err != nil {
return err
}
return yaml.NewEncoder(file).Encode(userConfig)
return ioutil.WriteFile(c.ConfigFilename(), out, 0666)
}
// ConfigFilename returns the filename of the current config file
func (c *AppConfig) ConfigFilename() string {
return filepath.Join(c.ConfigDir, "config.yml")
}

View file

@ -1,98 +0,0 @@
package config
import (
"os"
"testing"
"github.com/jesseduffield/yaml"
)
func TestDockerComposeCommandNoFiles(t *testing.T) {
composeFiles := []string{}
conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, composeFiles, "projectDir", "")
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
actual := conf.UserConfig.CommandTemplates.DockerCompose
expected := "docker compose"
if actual != expected {
t.Fatalf("Expected %s but got %s", expected, actual)
}
}
func TestDockerComposeCommandSingleFile(t *testing.T) {
composeFiles := []string{"one.yml"}
conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, composeFiles, "projectDir", "")
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
actual := conf.UserConfig.CommandTemplates.DockerCompose
expected := "docker compose -f one.yml"
if actual != expected {
t.Fatalf("Expected %s but got %s", expected, actual)
}
}
func TestDockerComposeCommandMultipleFiles(t *testing.T) {
composeFiles := []string{"one.yml", "two.yml", "three.yml"}
conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, composeFiles, "projectDir", "")
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
actual := conf.UserConfig.CommandTemplates.DockerCompose
expected := "docker compose -f one.yml -f two.yml -f three.yml"
if actual != expected {
t.Fatalf("Expected %s but got %s", expected, actual)
}
}
func TestWritingToConfigFile(t *testing.T) {
// init the AppConfig
emptyComposeFiles := []string{}
conf, err := NewAppConfig("name", "version", "commit", "date", "buildSource", false, emptyComposeFiles, "projectDir", "")
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
testFn := func(t *testing.T, ac *AppConfig, newValue bool) {
t.Helper()
updateFn := func(uc *UserConfig) error {
uc.ConfirmOnQuit = newValue
return nil
}
err = ac.WriteToUserConfig(updateFn)
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
file, err := os.OpenFile(ac.ConfigFilename(), os.O_RDONLY, 0o660)
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
sampleUC := UserConfig{}
err = yaml.NewDecoder(file).Decode(&sampleUC)
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
err = file.Close()
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
if sampleUC.ConfirmOnQuit != newValue {
t.Fatalf("Got %v, Expected %v\n", sampleUC.ConfirmOnQuit, newValue)
}
}
// insert value into an empty file
testFn(t, conf, true)
// modifying an existing file that already has 'ConfirmOnQuit'
testFn(t, conf, false)
}

View file

@ -1,4 +1,3 @@
//go:build !windows && !linux
// +build !windows,!linux
package config

View file

@ -1,8 +1,6 @@
package gui
import (
"time"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/utils"
)
@ -51,29 +49,19 @@ func (m *statusManager) getStatusString() string {
// WithWaitingStatus wraps a function and shows a waiting status while the function is still executing
func (gui *Gui) WithWaitingStatus(name string, f func() error) error {
go func() {
gui.statusManager.addWaitingStatus(name)
gui.g.Update(func(g *gocui.Gui) error {
gui.statusManager.addWaitingStatus(name)
return nil
})
defer func() {
defer gui.g.Update(func(g *gocui.Gui) error {
gui.statusManager.removeStatus(name)
}()
go func() {
ticker := time.NewTicker(time.Millisecond * 50)
defer ticker.Stop()
for range ticker.C {
appStatus := gui.statusManager.getStatusString()
if appStatus == "" {
return
}
if err := gui.renderString(gui.g, "appStatus", appStatus); err != nil {
gui.Log.Warn(err)
}
}
}()
return nil
})
if err := f(); err != nil {
gui.g.Update(func(g *gocui.Gui) error {
return gui.createErrorPanel(err.Error())
return gui.createErrorPanel(gui.g, err.Error())
})
}
}()

View file

@ -1,227 +0,0 @@
package gui
import (
"github.com/jesseduffield/lazycore/pkg/boxlayout"
"github.com/jesseduffield/lazydocker/pkg/gui/panels"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/mattn/go-runewidth"
"github.com/samber/lo"
)
// In this file we use the boxlayout package, along with knowledge about the app's state,
// to arrange the windows (i.e. panels) on the screen.
const INFO_SECTION_PADDING = " "
func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions {
minimumHeight := 9
minimumWidth := 10
width, height := gui.g.Size()
if width < minimumWidth || height < minimumHeight {
return boxlayout.ArrangeWindows(&boxlayout.Box{Window: "limit"}, 0, 0, width, height)
}
sideSectionWeight, mainSectionWeight := gui.getMidSectionWeights()
sidePanelsDirection := boxlayout.COLUMN
portraitMode := width <= 84 && height > 45
if portraitMode {
sidePanelsDirection = boxlayout.ROW
}
showInfoSection := gui.Config.UserConfig.Gui.ShowBottomLine || gui.State.Filter.active
infoSectionSize := 0
if showInfoSection {
infoSectionSize = 1
}
root := &boxlayout.Box{
Direction: boxlayout.ROW,
Children: []*boxlayout.Box{
{
Direction: sidePanelsDirection,
Weight: 1,
Children: []*boxlayout.Box{
{
Direction: boxlayout.ROW,
Weight: sideSectionWeight,
ConditionalChildren: gui.sidePanelChildren,
},
{
Window: "main",
Weight: mainSectionWeight,
},
},
},
{
Direction: boxlayout.COLUMN,
Size: infoSectionSize,
Children: gui.infoSectionChildren(informationStr, appStatus),
},
},
}
return boxlayout.ArrangeWindows(root, 0, 0, width, height)
}
func (gui *Gui) getMidSectionWeights() (int, int) {
currentWindow := gui.currentStaticWindowName()
// we originally specified this as a ratio i.e. .20 would correspond to a weight of 1 against 4
sidePanelWidthRatio := gui.Config.UserConfig.Gui.SidePanelWidth
// we could make this better by creating ratios like 2:3 rather than always 1:something
mainSectionWeight := int(1/sidePanelWidthRatio) - 1
sideSectionWeight := 1
if currentWindow == "main" && gui.State.ScreenMode == SCREEN_FULL {
mainSectionWeight = 1
sideSectionWeight = 0
} else {
if gui.State.ScreenMode == SCREEN_HALF {
mainSectionWeight = 1
} else if gui.State.ScreenMode == SCREEN_FULL {
mainSectionWeight = 0
}
}
return sideSectionWeight, mainSectionWeight
}
func (gui *Gui) infoSectionChildren(informationStr string, appStatus string) []*boxlayout.Box {
result := []*boxlayout.Box{}
if len(appStatus) > 0 {
result = append(result,
&boxlayout.Box{
Window: "appStatus",
Size: runewidth.StringWidth(appStatus) + runewidth.StringWidth(INFO_SECTION_PADDING),
},
)
}
if gui.State.Filter.active {
return append(result, []*boxlayout.Box{
{
Window: "filterPrefix",
Size: runewidth.StringWidth(gui.filterPrompt()),
},
{
Window: "filter",
Weight: 1,
},
}...)
}
result = append(result,
[]*boxlayout.Box{
{
Window: "options",
Weight: 1,
},
{
Window: "information",
// unlike appStatus, informationStr has various colors so we need to decolorise before taking the length
Size: runewidth.StringWidth(INFO_SECTION_PADDING) + runewidth.StringWidth(utils.Decolorise(informationStr)),
},
}...,
)
return result
}
func (gui *Gui) sideViewNames() []string {
visibleSidePanels := lo.Filter(gui.allSidePanels(), func(panel panels.ISideListPanel, _ int) bool {
return !panel.IsHidden()
})
return lo.Map(visibleSidePanels, func(panel panels.ISideListPanel, _ int) string {
return panel.GetView().Name()
})
}
func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box {
currentWindow := gui.currentSideWindowName()
sideWindowNames := gui.sideViewNames()
if gui.State.ScreenMode == SCREEN_FULL || gui.State.ScreenMode == SCREEN_HALF {
fullHeightBox := func(window string) *boxlayout.Box {
if window == currentWindow {
return &boxlayout.Box{
Window: window,
Weight: 1,
}
} else {
return &boxlayout.Box{
Window: window,
Size: 0,
}
}
}
return lo.Map(sideWindowNames, func(window string, _ int) *boxlayout.Box {
return fullHeightBox(window)
})
} else if height >= 28 {
accordionMode := gui.Config.UserConfig.Gui.ExpandFocusedSidePanel
accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box {
if accordionMode && defaultBox.Window == currentWindow {
return &boxlayout.Box{
Window: defaultBox.Window,
Weight: 2,
}
}
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{
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 {
return accordionBox(&boxlayout.Box{Window: window, Weight: 1})
})
} else {
squashedHeight := 1
if height >= 21 {
squashedHeight = 3
}
squashedSidePanelBox := func(window string) *boxlayout.Box {
if window == currentWindow {
return &boxlayout.Box{
Window: window,
Weight: 1,
}
} else {
return &boxlayout.Box{
Window: window,
Size: squashedHeight,
}
}
}
return lo.Map(sideWindowNames, func(window string, _ int) *boxlayout.Box {
return squashedSidePanelBox(window)
})
}
}

View file

@ -8,6 +8,7 @@ package gui
import (
"strings"
"time"
"github.com/fatih/color"
"github.com/jesseduffield/gocui"
@ -15,27 +16,25 @@ import (
func (gui *Gui) wrappedConfirmationFunction(function func(*gocui.Gui, *gocui.View) error) func(*gocui.Gui, *gocui.View) error {
return func(g *gocui.Gui, v *gocui.View) error {
if err := gui.closeConfirmationPrompt(); err != nil {
return err
}
if function != nil {
if err := function(g, v); err != nil {
return err
}
}
return nil
return gui.closeConfirmationPrompt(g)
}
}
func (gui *Gui) closeConfirmationPrompt() error {
if err := gui.returnFocus(); err != nil {
return err
func (gui *Gui) closeConfirmationPrompt(g *gocui.Gui) error {
view, err := g.View("confirmation")
if err != nil {
return nil // if it's already been closed we can just return
}
gui.g.DeleteViewKeybindings("confirmation")
gui.Views.Confirmation.Visible = false
return nil
if err := gui.returnFocus(g, view); err != nil {
panic(err)
}
g.DeleteKeybindings("confirmation")
return g.DeleteView("confirmation")
}
func (gui *Gui) getMessageHeight(wrap bool, message string, width int) int {
@ -52,8 +51,8 @@ func (gui *Gui) getMessageHeight(wrap bool, message string, width int) int {
return lineCount
}
func (gui *Gui) getConfirmationPanelDimensions(wrap bool, prompt string) (int, int, int, int) {
width, height := gui.g.Size()
func (gui *Gui) getConfirmationPanelDimensions(g *gocui.Gui, wrap bool, prompt string) (int, int, int, int) {
width, height := g.Size()
panelWidth := width / 2
panelHeight := gui.getMessageHeight(wrap, prompt, panelWidth)
return width/2 - panelWidth/2,
@ -62,56 +61,66 @@ func (gui *Gui) getConfirmationPanelDimensions(wrap bool, prompt string) (int, i
height/2 + panelHeight/2
}
func (gui *Gui) createPromptPanel(title string, handleConfirm func(*gocui.Gui, *gocui.View) error) error {
func (gui *Gui) createPromptPanel(g *gocui.Gui, currentView *gocui.View, title string, handleConfirm func(*gocui.Gui, *gocui.View) error) error {
gui.onNewPopupPanel()
err := gui.prepareConfirmationPanel(title, "")
confirmationView, err := gui.prepareConfirmationPanel(currentView, title, "", false)
if err != nil {
return err
}
gui.Views.Confirmation.Editable = true
return gui.setKeyBindings(gui.g, handleConfirm, nil)
confirmationView.Editable = true
return gui.setKeyBindings(g, handleConfirm, nil)
}
func (gui *Gui) prepareConfirmationPanel(title, prompt string) error {
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(true, prompt)
confirmationView := gui.Views.Confirmation
_, err := gui.g.SetView("confirmation", x0, y0, x1, y1, 0)
func (gui *Gui) prepareConfirmationPanel(currentView *gocui.View, title, prompt string, hasLoader bool) (*gocui.View, error) {
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(gui.g, true, prompt)
confirmationView, err := gui.g.SetView("confirmation", x0, y0, x1, y1, 0)
if err != nil {
return err
if err.Error() != "unknown view" {
return nil, err
}
confirmationView.HasLoader = hasLoader
confirmationView.Title = title
confirmationView.Wrap = true
confirmationView.FgColor = gocui.ColorWhite
}
confirmationView.Title = title
confirmationView.Visible = true
gui.g.Update(func(g *gocui.Gui) error {
return gui.switchFocus(confirmationView)
return gui.switchFocus(gui.g, currentView, confirmationView)
})
return nil
return confirmationView, nil
}
func (gui *Gui) onNewPopupPanel() {
gui.Views.Menu.Visible = false
gui.Views.Confirmation.Visible = false
viewNames := []string{"commitMessage",
"credentials",
"menu"}
for _, viewName := range viewNames {
_, _ = gui.g.SetViewOnBottom(viewName)
}
}
// It is very important that within this function we never include the original prompt in any error messages, because it may contain e.g. a user password.
// The golangcilint unparam linter complains that handleClose is alwans nil but one day it won't be nil.
// nolint:unparam
func (gui *Gui) createConfirmationPanel(title, prompt string, handleConfirm, handleClose func(*gocui.Gui, *gocui.View) error) error {
return gui.createPopupPanel(title, prompt, handleConfirm, handleClose)
func (gui *Gui) createLoaderPanel(g *gocui.Gui, currentView *gocui.View, prompt string) error {
return gui.createPopupPanel(g, currentView, "", prompt, true, nil, nil)
}
func (gui *Gui) createPopupPanel(title, prompt string, handleConfirm, handleClose func(*gocui.Gui, *gocui.View) error) error {
// it is very important that within this function we never include the original prompt in any error messages, because it may contain e.g. a user password
func (gui *Gui) createConfirmationPanel(g *gocui.Gui, currentView *gocui.View, title, prompt string, handleConfirm, handleClose func(*gocui.Gui, *gocui.View) error) error {
return gui.createPopupPanel(g, currentView, title, prompt, false, handleConfirm, handleClose)
}
func (gui *Gui) createPopupPanel(g *gocui.Gui, currentView *gocui.View, title, prompt string, hasLoader bool, handleConfirm, handleClose func(*gocui.Gui, *gocui.View) error) error {
gui.onNewPopupPanel()
gui.g.Update(func(g *gocui.Gui) error {
if gui.currentViewName() == "confirmation" {
if err := gui.closeConfirmationPrompt(); err != nil {
g.Update(func(g *gocui.Gui) error {
// delete the existing confirmation panel if it exists
if view, _ := g.View("confirmation"); view != nil {
if err := gui.closeConfirmationPrompt(g); err != nil {
gui.Log.Error(err.Error())
}
}
err := gui.prepareConfirmationPanel(title, prompt)
confirmationView, err := gui.prepareConfirmationPanel(currentView, title, prompt, hasLoader)
if err != nil {
return err
}
gui.Views.Confirmation.Editable = false
confirmationView.Editable = false
if err := gui.renderString(g, "confirmation", prompt); err != nil {
return err
}
@ -121,34 +130,37 @@ func (gui *Gui) createPopupPanel(title, prompt string, handleConfirm, handleClos
}
func (gui *Gui) setKeyBindings(g *gocui.Gui, handleConfirm, handleClose func(*gocui.Gui, *gocui.View) error) error {
// would use a loop here but because the function takes an interface{} and slices of interfaces require even more boilerplate
if err := g.SetKeybinding("confirmation", gocui.KeyEnter, gocui.ModNone, gui.wrappedConfirmationFunction(handleConfirm)); err != nil {
return err
}
if err := g.SetKeybinding("confirmation", 'y', gocui.ModNone, gui.wrappedConfirmationFunction(handleConfirm)); err != nil {
return err
}
if err := g.SetKeybinding("confirmation", gocui.KeyEsc, gocui.ModNone, gui.wrappedConfirmationFunction(handleClose)); err != nil {
return err
}
if err := g.SetKeybinding("confirmation", 'n', gocui.ModNone, gui.wrappedConfirmationFunction(handleClose)); err != nil {
return err
}
return nil
return g.SetKeybinding("confirmation", gocui.KeyEsc, gocui.ModNone, gui.wrappedConfirmationFunction(handleClose))
}
func (gui *Gui) createErrorPanel(message string) error {
func (gui *Gui) createMessagePanel(g *gocui.Gui, currentView *gocui.View, title, prompt string) error {
return gui.createPopupPanel(g, currentView, title, prompt, false, nil, nil)
}
// createSpecificErrorPanel allows you to create an error popup, specifying the
// view to be focused when the user closes the popup, and a boolean specifying
// whether we will log the error. If the message may include a user password,
// this function is to be used over the more generic createErrorPanel, with
// willLog set to false
func (gui *Gui) createSpecificErrorPanel(message string, nextView *gocui.View, willLog bool) error {
if willLog {
go func() {
// when reporting is switched on this log call sometimes introduces
// a delay on the error panel popping up. Here I'm adding a second wait
// so that the error is logged while the user is reading the error message
time.Sleep(time.Second)
gui.Log.Error(message)
}()
}
colorFunction := color.New(color.FgRed).SprintFunc()
coloredMessage := colorFunction(strings.TrimSpace(message))
return gui.createConfirmationPanel(gui.Tr.ErrorTitle, coloredMessage, nil, nil)
return gui.createConfirmationPanel(gui.g, nextView, gui.Tr.ErrorTitle, coloredMessage, nil, nil)
}
func (gui *Gui) renderConfirmationOptions() error {
optionsMap := map[string]string{
"n/esc": gui.Tr.No,
"y/enter": gui.Tr.Yes,
}
return gui.renderOptionsMap(optionsMap)
func (gui *Gui) createErrorPanel(g *gocui.Gui, message string) error {
return gui.createSpecificErrorPanel(message, g.CurrentView(), true)
}

View file

@ -1,152 +0,0 @@
package gui
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
"github.com/fatih/color"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/jesseduffield/lazydocker/pkg/utils"
)
func (gui *Gui) renderContainerLogsToMain(container *commands.Container) tasks.TaskFunc {
return gui.NewTickerTask(TickerTaskOpts{
Func: func(ctx context.Context, notifyStopped chan struct{}) {
gui.renderContainerLogsToMainAux(container, ctx, notifyStopped)
},
Duration: time.Millisecond * 200,
// TODO: see why this isn't working (when switching from Top tab to Logs tab in the services panel, the tops tab's content isn't removed)
Before: func(ctx context.Context) { gui.clearMainView() },
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
Autoscroll: true,
})
}
func (gui *Gui) renderContainerLogsToMainAux(container *commands.Container, ctx context.Context, notifyStopped chan struct{}) {
gui.clearMainView()
defer func() {
notifyStopped <- struct{}{}
}()
mainView := gui.Views.Main
if err := gui.writeContainerLogs(container, ctx, mainView); err != nil {
gui.Log.Error(err)
}
// if we are here because the task has been stopped, we should return
// if we are here then the container must have exited, meaning we should wait until it's back again before
ticker := time.NewTicker(time.Millisecond * 100)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
result, err := container.Inspect()
if err != nil {
// if we get an error, then the container has probably been removed so we'll get out of here
gui.Log.Error(err)
return
}
if result.State.Running {
return
}
}
}
}
func (gui *Gui) renderLogsToStdout(container *commands.Container) {
stop := make(chan os.Signal, 1)
defer signal.Stop(stop)
ctx, cancel := context.WithCancel(context.Background())
go func() {
signal.Notify(stop, os.Interrupt)
<-stop
cancel()
}()
if err := gui.g.Suspend(); err != nil {
gui.Log.Error(err)
return
}
defer func() {
if err := gui.g.Resume(); err != nil {
gui.Log.Error(err)
}
}()
if err := gui.writeContainerLogs(container, ctx, os.Stdout); err != nil {
gui.Log.Error(err)
return
}
gui.promptToReturn()
}
func (gui *Gui) promptToReturn() {
if !gui.Config.UserConfig.Gui.ReturnImmediately {
fmt.Fprintf(os.Stdout, "\n\n%s", utils.ColoredString(gui.Tr.PressEnterToReturn, color.FgGreen))
// wait for enter press
if _, err := fmt.Scanln(); err != nil {
gui.Log.Error(err)
}
}
}
func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context, writer io.Writer) error {
readCloser, err := gui.DockerCommand.Client.ContainerLogs(ctx, ctr.ID, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Timestamps: gui.Config.UserConfig.Logs.Timestamps,
Since: gui.Config.UserConfig.Logs.Since,
Tail: gui.Config.UserConfig.Logs.Tail,
Follow: true,
})
if err != nil {
gui.Log.Error(err)
return err
}
defer readCloser.Close()
if !ctr.DetailsLoaded() {
// loop until the details load or context is cancelled, using timer
ticker := time.NewTicker(time.Millisecond * 100)
defer ticker.Stop()
outer:
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if ctr.DetailsLoaded() {
break outer
}
}
}
}
if ctr.Details.Config.Tty {
_, err = io.Copy(writer, readCloser)
if err != nil {
return err
}
} else {
_, err = stdcopy.StdCopy(writer, writer, readCloser)
if err != nil {
return err
}
}
return nil
}

View file

@ -1,441 +1,500 @@
package gui
import (
"context"
"bufio"
"encoding/json"
"fmt"
"strings"
"os/exec"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types"
"github.com/fatih/color"
"github.com/go-errors/errors"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/gui/panels"
"github.com/jesseduffield/lazydocker/pkg/gui/presentation"
"github.com/jesseduffield/lazydocker/pkg/gui/types"
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
)
func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container] {
// Standalone containers are containers which are either one-off containers, or whose service is not part of this docker-compose context.
isStandaloneContainer := func(container *commands.Container) bool {
if container.OneOff || container.ServiceName == "" {
return true
}
// list panel functions
return !lo.SomeBy(gui.Panels.Services.List.GetAllItems(), func(service *commands.Service) bool {
return service.Name == container.ServiceName && service.ProjectName == container.ProjectName
})
}
return &panels.SideListPanel[*commands.Container]{
ContextState: &panels.ContextState[*commands.Container]{
GetMainTabs: func() []panels.MainTab[*commands.Container] {
return []panels.MainTab[*commands.Container]{
{
Key: "logs",
Title: gui.Tr.LogsTitle,
Render: gui.renderContainerLogsToMain,
},
{
Key: "stats",
Title: gui.Tr.StatsTitle,
Render: gui.renderContainerStats,
},
{
Key: "env",
Title: gui.Tr.EnvTitle,
Render: gui.renderContainerEnv,
},
{
Key: "config",
Title: gui.Tr.ConfigTitle,
Render: gui.renderContainerConfig,
},
{
Key: "top",
Title: gui.Tr.TopTitle,
Render: gui.renderContainerTop,
},
}
},
GetItemContextCacheKey: func(container *commands.Container) string {
// Including the container state in the cache key so that if the container
// restarts we re-read the logs. In the past we've had some glitchiness
// where a container restarts but the new logs don't get read.
// Note that this might be jarring if we have a lot of logs and the container
// restarts a lot, so let's keep an eye on it.
return "containers-" + container.ID + "-" + container.Container.State
},
},
ListPanel: panels.ListPanel[*commands.Container]{
List: panels.NewFilteredList[*commands.Container](),
View: gui.Views.Containers,
},
NoItemsMessage: gui.Tr.NoContainers,
Gui: gui.intoInterface(),
// sortedContainers returns containers sorted by state if c.SortContainersByState is true (follows 1- running, 2- exited, 3- created)
// and sorted by name if c.SortContainersByState is false
Sort: func(a *commands.Container, b *commands.Container) bool {
return sortContainers(a, b, gui.Config.UserConfig.Gui.LegacySortContainers)
},
Filter: func(container *commands.Container) bool {
if !gui.State.ShowExitedContainers && container.Container.State == "exited" {
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
}
}
return true
},
GetTableCells: func(container *commands.Container) []string {
return presentation.GetContainerDisplayStrings(&gui.Config.UserConfig.Gui, container)
},
}
func (gui *Gui) getContainerContexts() []string {
return []string{"logs", "stats", "config", "top"}
}
var containerStates = map[string]int{
"running": 1,
"exited": 2,
"created": 3,
func (gui *Gui) getContainerContextTitles() []string {
return []string{gui.Tr.LogsTitle, gui.Tr.StatsTitle, gui.Tr.ConfigTitle, gui.Tr.TopTitle}
}
func sortContainers(a *commands.Container, b *commands.Container, legacySort bool) bool {
if legacySort {
return a.Name < b.Name
func (gui *Gui) getSelectedContainer() (*commands.Container, error) {
selectedLine := gui.State.Panels.Containers.SelectedLine
if selectedLine == -1 {
return &commands.Container{}, gui.Errors.ErrNoContainers
}
stateLeft := containerStates[a.Container.State]
stateRight := containerStates[b.Container.State]
if stateLeft == stateRight {
return a.Name < b.Name
}
return containerStates[a.Container.State] < containerStates[b.Container.State]
return gui.DockerCommand.DisplayContainers[selectedLine], nil
}
func (gui *Gui) renderContainerEnv(container *commands.Container) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string { return gui.containerEnv(container) })
}
func (gui *Gui) containerEnv(container *commands.Container) string {
if !container.DetailsLoaded() {
return gui.Tr.WaitingForContainerInfo
}
if len(container.Details.Config.Env) == 0 {
return gui.Tr.NothingToDisplay
}
envVarsList := lo.Map(container.Details.Config.Env, func(envVar string, _ int) []string {
splitEnv := strings.SplitN(envVar, "=", 2)
key := splitEnv[0]
value := ""
if len(splitEnv) > 1 {
value = splitEnv[1]
}
return []string{
utils.ColoredString(key+":", color.FgGreen),
utils.ColoredString(value, color.FgYellow),
}
})
output, err := utils.RenderTable(envVarsList)
if err != nil {
gui.Log.Error(err)
return gui.Tr.CannotDisplayEnvVariables
}
return output
}
func (gui *Gui) renderContainerConfig(container *commands.Container) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string { return gui.containerConfigStr(container) })
}
func (gui *Gui) containerConfigStr(container *commands.Container) string {
if !container.DetailsLoaded() {
return gui.Tr.WaitingForContainerInfo
}
padding := 10
output := ""
output += utils.WithPadding("ID: ", padding) + container.ID + "\n"
output += utils.WithPadding("Name: ", padding) + container.Name + "\n"
output += utils.WithPadding("Image: ", padding) + container.Details.Config.Image + "\n"
output += utils.WithPadding("Command: ", padding) + strings.Join(append([]string{container.Details.Path}, container.Details.Args...), " ") + "\n"
output += utils.WithPadding("Labels: ", padding) + utils.FormatMap(padding, container.Details.Config.Labels)
output += "\n"
output += utils.WithPadding("Mounts: ", padding)
if len(container.Details.Mounts) > 0 {
output += "\n"
for _, mount := range container.Details.Mounts {
if mount.Type == "volume" {
output += fmt.Sprintf("%s%s %s\n", strings.Repeat(" ", padding), utils.ColoredString(string(mount.Type)+":", color.FgYellow), mount.Name)
} else {
output += fmt.Sprintf("%s%s %s:%s\n", strings.Repeat(" ", padding), utils.ColoredString(string(mount.Type)+":", color.FgYellow), mount.Source, mount.Destination)
}
}
} else {
output += "none\n"
}
output += utils.WithPadding("Ports: ", padding)
if len(container.Details.NetworkSettings.Ports) > 0 {
output += "\n"
for k, v := range container.Details.NetworkSettings.Ports {
for _, host := range v {
output += fmt.Sprintf("%s%s %s\n", strings.Repeat(" ", padding), utils.ColoredString(host.HostPort+":", color.FgYellow), k)
}
}
} else {
output += "none\n"
}
data, err := utils.MarshalIntoYaml(&container.Details)
if err != nil {
return fmt.Sprintf("Error marshalling container details: %v", err)
}
output += fmt.Sprintf("\nFull details:\n\n%s", utils.ColoredYamlString(string(data)))
return output
}
func (gui *Gui) renderContainerStats(container *commands.Container) tasks.TaskFunc {
return gui.NewTickerTask(TickerTaskOpts{
Func: func(ctx context.Context, notifyStopped chan struct{}) {
contents, err := presentation.RenderStats(gui.Config.UserConfig, container, gui.Views.Main.Width())
if err != nil {
_ = gui.createErrorPanel(err.Error())
}
gui.reRenderStringMain(contents)
},
Duration: time.Second,
Before: func(ctx context.Context) { gui.clearMainView() },
Wrap: false, // wrapping looks bad here so we're overriding the config value
Autoscroll: false,
})
}
func (gui *Gui) renderContainerTop(container *commands.Container) tasks.TaskFunc {
return gui.NewTickerTask(TickerTaskOpts{
Func: func(ctx context.Context, notifyStopped chan struct{}) {
contents, err := container.RenderTop(ctx)
if err != nil {
gui.RenderStringMain(err.Error())
}
gui.reRenderStringMain(contents)
},
Duration: time.Second,
Before: func(ctx context.Context) { gui.clearMainView() },
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
Autoscroll: false,
})
}
func (gui *Gui) refreshContainersAndServices() error {
if gui.Views.Containers == nil {
// if the containersView hasn't been instantiated yet we just return
func (gui *Gui) handleContainersFocus(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() {
return nil
}
// keep track of current service selected so that we can reposition our cursor if it moves position in the list
originalSelectedLineIdx := gui.Panels.Services.SelectedIdx
selectedService, isServiceSelected := gui.Panels.Services.List.TryGet(originalSelectedLineIdx)
cx, cy := v.Cursor()
_, oy := v.Origin()
containers, services, err := gui.DockerCommand.RefreshContainersAndServices(
gui.Panels.Containers.List.GetAllItems(),
)
if err != nil {
return err
prevSelectedLine := gui.State.Panels.Containers.SelectedLine
newSelectedLine := cy - oy
if newSelectedLine > len(gui.DockerCommand.DisplayContainers)-1 || len(utils.Decolorise(gui.DockerCommand.DisplayContainers[newSelectedLine].Name)) < cx {
return gui.handleContainerSelect(gui.g, v)
}
gui.Panels.Services.SetItems(services)
gui.Panels.Containers.SetItems(containers)
gui.State.Panels.Containers.SelectedLine = newSelectedLine
// see if our selected service has moved
if isServiceSelected {
for i, service := range gui.Panels.Services.List.GetItems() {
if service.ID == selectedService.ID {
if i == originalSelectedLineIdx {
break
}
gui.Panels.Services.SetSelectedLineIdx(i)
gui.Panels.Services.Refocus()
}
}
if prevSelectedLine == newSelectedLine && gui.currentViewName() == v.Name() {
return nil
}
return gui.renderContainersAndServices()
return gui.handleContainerSelect(gui.g, v)
}
func (gui *Gui) renderContainersAndServices() error {
if err := gui.Panels.Services.RerenderList(); err != nil {
func (gui *Gui) handleContainerSelect(g *gocui.Gui, v *gocui.View) error {
if _, err := gui.g.SetCurrentView(v.Name()); err != nil {
return err
}
if err := gui.Panels.Containers.RerenderList(); err != nil {
container, err := gui.getSelectedContainer()
if err != nil {
if err != gui.Errors.ErrNoContainers {
return err
}
return nil
}
key := "containers-" + container.ID + "-" + gui.getContainerContexts()[gui.State.Panels.Containers.ContextIndex]
if gui.State.Panels.Main.ObjectKey == key {
return nil
} else {
gui.State.Panels.Main.ObjectKey = key
}
if err := gui.focusPoint(0, gui.State.Panels.Containers.SelectedLine, len(gui.DockerCommand.DisplayContainers), v); err != nil {
return err
}
mainView := gui.getMainView()
mainView.Tabs = gui.getContainerContextTitles()
mainView.TabIndex = gui.State.Panels.Containers.ContextIndex
gui.clearMainView()
switch gui.getContainerContexts()[gui.State.Panels.Containers.ContextIndex] {
case "logs":
if err := gui.renderContainerLogs(container); err != nil {
return err
}
case "config":
if err := gui.renderContainerConfig(container); err != nil {
return err
}
case "stats":
if err := gui.renderContainerStats(container); err != nil {
return err
}
case "top":
if err := gui.renderContainerTop(container); err != nil {
return err
}
default:
return errors.New("Unknown context for containers panel")
}
return nil
}
func (gui *Gui) handleHideStoppedContainers(g *gocui.Gui, v *gocui.View) error {
gui.State.ShowExitedContainers = !gui.State.ShowExitedContainers
func (gui *Gui) renderContainerConfig(container *commands.Container) error {
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = true
return gui.Panels.Containers.RerenderList()
data, err := json.MarshalIndent(&container.Details, "", " ")
if err != nil {
return err
}
return gui.T.NewTask(func(stop chan struct{}) {
gui.renderString(gui.g, "main", string(data))
})
}
func (gui *Gui) renderContainerStats(container *commands.Container) error {
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = false
return gui.T.NewTickerTask(time.Second, func(stop chan struct{}) { gui.clearMainView() }, func(stop, notifyStopped chan struct{}) {
width, _ := mainView.Size()
contents, err := container.RenderStats(width)
if err != nil {
gui.createErrorPanel(gui.g, err.Error())
}
gui.reRenderString(gui.g, "main", contents)
})
}
func (gui *Gui) renderContainerTop(container *commands.Container) error {
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = false
return gui.T.NewTickerTask(time.Second, func(stop chan struct{}) { gui.clearMainView() }, func(stop, notifyStopped chan struct{}) {
contents, err := container.RenderTop()
if err != nil {
gui.reRenderString(gui.g, "main", err.Error())
}
gui.reRenderString(gui.g, "main", contents)
})
}
func (gui *Gui) renderContainerLogs(container *commands.Container) error {
mainView := gui.getMainView()
mainView.Autoscroll = true
mainView.Wrap = true
if container.Details.Config.OpenStdin {
return gui.renderLogsForTTYContainer(container)
}
return gui.renderLogsForRegularContainer(container)
}
func (gui *Gui) renderLogsForRegularContainer(container *commands.Container) error {
return gui.renderLogs(container, gui.Config.UserConfig.CommandTemplates.ContainerLogs, func(cmd *exec.Cmd) {
mainView := gui.getMainView()
cmd.Stdout = mainView
cmd.Stderr = mainView
})
}
func (gui *Gui) renderLogsForTTYContainer(container *commands.Container) error {
return gui.renderLogs(container, gui.Config.UserConfig.CommandTemplates.ContainerTTYLogs, func(cmd *exec.Cmd) {
// for some reason just saying cmd.Stdout = mainView does not work here as it does for non-tty containers, so we feed it through line by line
r, err := cmd.StdoutPipe()
if err != nil {
gui.ErrorChan <- err
}
go func() {
mainView := gui.getMainView()
s := bufio.NewScanner(r)
s.Split(bufio.ScanLines)
for s.Scan() {
// I might put a check on the stopped channel here. Would mean more code duplication though
mainView.Write(append(s.Bytes(), '\n'))
}
}()
})
}
func (gui *Gui) renderLogs(container *commands.Container, template string, setup func(*exec.Cmd)) error {
return gui.T.NewTickerTask(time.Millisecond*200, nil, func(stop, notifyStopped chan struct{}) {
gui.clearMainView()
command := utils.ApplyTemplate(
template,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Container: container}),
)
cmd := gui.OSCommand.RunCustomCommand(command)
setup(cmd)
cmd.Start()
go func() {
<-stop
cmd.Process.Kill()
gui.Log.Info("killed container logs process")
return
}()
cmd.Wait()
// if we are here because the task has been stopped, we should return
// if we are here then the container must have exited, meaning we should wait until it's back again before
L:
for {
select {
case <-stop:
return
default:
result, err := container.Inspect()
if err != nil {
// if we get an error, then the container has probably been removed so we'll get out of here
gui.Log.Error(err)
notifyStopped <- struct{}{}
return
}
if result.State.Running {
break L
}
time.Sleep(time.Millisecond * 100)
}
}
})
}
func (gui *Gui) refreshContainersAndServices() error {
containersView := gui.getContainersView()
if containersView == nil {
// if the containersView hasn't been instantiated yet we just return
return nil
}
// keep track of current service selected so that we can reposition our cursor if it moves position in the list
sl := gui.State.Panels.Services.SelectedLine
var selectedService *commands.Service
if len(gui.DockerCommand.Services) > 0 {
selectedService = gui.DockerCommand.Services[sl]
}
if err := gui.DockerCommand.RefreshContainersAndServices(); err != nil {
return err
}
// see if our selected service has moved
if selectedService != nil {
for i, service := range gui.DockerCommand.Services {
if service.ID == selectedService.ID {
if i == sl {
break
}
gui.State.Panels.Services.SelectedLine = i
if err := gui.focusPoint(0, i, len(gui.DockerCommand.Services), gui.getServicesView()); err != nil {
return err
}
}
}
}
if len(gui.DockerCommand.DisplayContainers) > 0 && gui.State.Panels.Containers.SelectedLine == -1 {
gui.State.Panels.Containers.SelectedLine = 0
}
if len(gui.DockerCommand.DisplayContainers)-1 < gui.State.Panels.Containers.SelectedLine {
gui.State.Panels.Containers.SelectedLine = len(gui.DockerCommand.DisplayContainers) - 1
}
// doing the exact same thing for services
if len(gui.DockerCommand.Services) > 0 && gui.State.Panels.Services.SelectedLine == -1 {
gui.State.Panels.Services.SelectedLine = 0
}
if len(gui.DockerCommand.Services)-1 < gui.State.Panels.Services.SelectedLine {
gui.State.Panels.Services.SelectedLine = len(gui.DockerCommand.Services) - 1
}
gui.g.Update(func(g *gocui.Gui) error {
containersView.Clear()
isFocused := gui.g.CurrentView().Name() == "containers"
list, err := utils.RenderList(gui.DockerCommand.DisplayContainers, utils.IsFocused(isFocused))
if err != nil {
return err
}
fmt.Fprint(containersView, list)
if containersView == g.CurrentView() {
if err := gui.handleContainerSelect(g, containersView); err != nil {
return err
}
}
// doing the exact same thing for services
if !gui.DockerCommand.InDockerComposeProject {
return nil
}
servicesView := gui.getServicesView()
servicesView.Clear()
isFocused = gui.g.CurrentView().Name() == "services"
list, err = utils.RenderList(gui.DockerCommand.Services, utils.IsFocused(isFocused))
if err != nil {
return err
}
fmt.Fprint(servicesView, list)
if servicesView == g.CurrentView() {
return gui.handleServiceSelect(g, servicesView)
}
return nil
})
return nil
}
func (gui *Gui) handleContainersNextLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() {
return nil
}
panelState := gui.State.Panels.Containers
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.DisplayContainers), false)
return gui.handleContainerSelect(gui.g, v)
}
func (gui *Gui) handleContainersPrevLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() {
return nil
}
panelState := gui.State.Panels.Containers
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.DisplayContainers), true)
return gui.handleContainerSelect(gui.g, v)
}
func (gui *Gui) handleContainersNextContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getContainerContexts()
if gui.State.Panels.Containers.ContextIndex >= len(contexts)-1 {
gui.State.Panels.Containers.ContextIndex = 0
} else {
gui.State.Panels.Containers.ContextIndex++
}
gui.handleContainerSelect(gui.g, v)
return nil
}
func (gui *Gui) handleContainersPrevContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getContainerContexts()
if gui.State.Panels.Containers.ContextIndex <= 0 {
gui.State.Panels.Containers.ContextIndex = len(contexts) - 1
} else {
gui.State.Panels.Containers.ContextIndex--
}
gui.handleContainerSelect(gui.g, v)
return nil
}
type removeContainerOption struct {
description string
command string
configOptions types.ContainerRemoveOptions
runCommand bool
}
// GetDisplayStrings is a function.
func (r *removeContainerOption) GetDisplayStrings(isFocused bool) []string {
return []string{r.description, color.New(color.FgRed).Sprint(r.command)}
}
func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
container, err := gui.getSelectedContainer()
if err != nil {
return nil
}
handleMenuPress := func(configOptions container.RemoveOptions) error {
options := []*removeContainerOption{
{
description: gui.Tr.Remove,
command: "docker rm " + container.ID[1:10],
configOptions: types.ContainerRemoveOptions{},
},
{
description: gui.Tr.RemoveWithVolumes,
command: "docker rm --volumes " + container.ID[1:10],
configOptions: types.ContainerRemoveOptions{RemoveVolumes: true},
},
{
description: gui.Tr.Cancel,
},
}
handleMenuPress := func(index int) error {
if options[index].command == "" {
return nil
}
configOptions := options[index].configOptions
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
if err := ctr.Remove(configOptions); err != nil {
if err := container.Remove(configOptions); err != nil {
if commands.HasErrorCode(err, commands.MustStopContainer) {
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.MustForceToRemoveContainer, func(g *gocui.Gui, v *gocui.View) error {
return gui.createConfirmationPanel(gui.g, v, gui.Tr.Confirm, gui.Tr.MustForceToRemoveContainer, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
configOptions.Force = true
return ctr.Remove(configOptions)
if err := container.Remove(configOptions); err != nil {
return err
}
return gui.refreshContainersAndServices()
})
}, nil)
}
return gui.createErrorPanel(err.Error())
return gui.createErrorPanel(gui.g, err.Error())
}
return nil
return gui.refreshContainersAndServices()
})
}
menuItems := []*types.MenuItem{
{
LabelColumns: []string{gui.Tr.Remove, "docker rm " + ctr.ID[1:10]},
OnPress: func() error { return handleMenuPress(container.RemoveOptions{}) },
},
{
LabelColumns: []string{gui.Tr.RemoveWithVolumes, "docker rm --volumes " + ctr.ID[1:10]},
OnPress: func() error { return handleMenuPress(container.RemoveOptions{RemoveVolumes: true}) },
},
}
return gui.Menu(CreateMenuOptions{
Title: "",
Items: menuItems,
})
return gui.createMenu("", options, len(options), handleMenuPress)
}
func (gui *Gui) PauseContainer(container *commands.Container) error {
return gui.WithWaitingStatus(gui.Tr.PausingStatus, func() (err error) {
if container.Details.State.Paused {
err = container.Unpause()
} else {
err = container.Pause()
}
func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
if err != nil {
return nil
}
if err != nil {
return gui.createErrorPanel(err.Error())
return gui.createConfirmationPanel(gui.g, v, gui.Tr.Confirm, gui.Tr.StopContainer, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error {
if err := container.Stop(); err != nil {
return gui.createErrorPanel(gui.g, err.Error())
}
return gui.refreshContainersAndServices()
})
}, nil)
}
func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
if err != nil {
return nil
}
return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error {
if err := container.Restart(); err != nil {
return gui.createErrorPanel(gui.g, err.Error())
}
return gui.refreshContainersAndServices()
})
}
func (gui *Gui) handleContainerPause(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
return gui.PauseContainer(ctr)
}
func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.StopContainer, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error {
if err := ctr.Stop(); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
}, nil)
}
func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error {
if err := ctr.Restart(); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
}
func (gui *Gui) handleContainerAttach(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
container, err := gui.getSelectedContainer()
if err != nil {
return nil
}
c, err := ctr.Attach()
c, err := container.Attach()
if err != nil {
return gui.createErrorPanel(err.Error())
return gui.createErrorPanel(gui.g, err.Error())
}
return gui.runSubprocessWithMessage(c, gui.Tr.DetachFromContainerShortCut)
gui.SubProcess = c
return gui.Errors.ErrSubProcess
}
func (gui *Gui) handlePruneContainers() error {
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmPruneContainers, func(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handlePruneContainers(g *gocui.Gui, v *gocui.View) error {
return gui.createConfirmationPanel(gui.g, v, gui.Tr.Confirm, gui.Tr.ConfirmPruneContainers, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.PruningStatus, func() error {
err := gui.DockerCommand.PruneContainers()
if err != nil {
return gui.createErrorPanel(err.Error())
return gui.createErrorPanel(gui.g, err.Error())
}
return nil
})
@ -443,126 +502,31 @@ func (gui *Gui) handlePruneContainers() error {
}
func (gui *Gui) handleContainerViewLogs(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
container, err := gui.getSelectedContainer()
if err != nil {
return nil
}
gui.renderLogsToStdout(ctr)
return nil
}
func (gui *Gui) handleContainersExecShell(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
c, err := container.ViewLogs()
if err != nil {
return nil
return gui.createErrorPanel(gui.g, err.Error())
}
return gui.containerExecShell(ctr)
}
func (gui *Gui) containerExecShell(container *commands.Container) error {
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{
Container: container,
})
// TODO: use SDK
resolvedCommand := utils.ApplyTemplate("docker exec -it {{ .Container.ID }} /bin/sh -c 'eval $(grep ^$(id -un): /etc/passwd | cut -d : -f 7-)'", commandObject)
// attach and return the subprocess error
cmd := gui.OSCommand.ExecutableFromString(resolvedCommand)
return gui.runSubprocess(cmd)
gui.SubProcess = c
return gui.Errors.ErrSubProcess
}
func (gui *Gui) handleContainersCustomCommand(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
container, err := gui.getSelectedContainer()
if err != nil {
return nil
}
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{
Container: ctr,
Container: container,
})
customCommands := gui.Config.UserConfig.CustomCommands.Containers
return gui.createCustomCommandMenu(customCommands, commandObject)
}
func (gui *Gui) handleStopContainers() error {
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmStopContainers, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error {
for _, ctr := range gui.Panels.Containers.List.GetAllItems() {
if err := ctr.Stop(); err != nil {
gui.Log.Error(err)
}
}
return nil
})
}, nil)
}
func (gui *Gui) handleRemoveContainers() error {
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmRemoveContainers, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
for _, ctr := range gui.Panels.Containers.List.GetAllItems() {
if err := ctr.Remove(container.RemoveOptions{Force: true}); err != nil {
gui.Log.Error(err)
}
}
return nil
})
}, nil)
}
func (gui *Gui) handleContainersBulkCommand(g *gocui.Gui, v *gocui.View) error {
baseBulkCommands := []config.CustomCommand{
{
Name: gui.Tr.StopAllContainers,
InternalFunction: gui.handleStopContainers,
},
{
Name: gui.Tr.RemoveAllContainers,
InternalFunction: gui.handleRemoveContainers,
},
{
Name: gui.Tr.PruneContainers,
InternalFunction: gui.handlePruneContainers,
},
}
bulkCommands := append(baseBulkCommands, gui.Config.UserConfig.BulkCommands.Containers...)
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{})
return gui.createBulkCommandMenu(bulkCommands, commandObject)
}
// Open first port in browser
func (gui *Gui) handleContainersOpenInBrowserCommand(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
return gui.openContainerInBrowser(ctr)
}
func (gui *Gui) openContainerInBrowser(ctr *commands.Container) error {
// skip if no any ports
if len(ctr.Container.Ports) == 0 {
return nil
}
// skip if the first port is not published
port := ctr.Container.Ports[0]
if port.IP == "" {
return nil
}
ip := port.IP
if ip == "0.0.0.0" {
ip = "localhost"
}
link := fmt.Sprintf("http://%s:%d/", ip, port.PublicPort)
return gui.OSCommand.OpenLink(link)
}

View file

@ -1,59 +1,62 @@
package gui
import (
"github.com/fatih/color"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/gui/types"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
)
func (gui *Gui) createCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject, title string, waitingStatus string) error {
menuItems := lo.Map(customCommands, func(command config.CustomCommand, _ int) *types.MenuItem {
resolvedCommand := utils.ApplyTemplate(command.Command, commandObject)
type customCommandOption struct {
customCommand config.CustomCommand
description string
command string
runCommand bool
attach bool
}
onPress := func() error {
if command.InternalFunction != nil {
return command.InternalFunction()
}
if command.Shell {
resolvedCommand = gui.OSCommand.NewCommandStringWithShell(resolvedCommand)
}
// if we have a command for attaching, we attach and return the subprocess error
if command.Attach {
return gui.runSubprocess(gui.OSCommand.ExecutableFromString(resolvedCommand))
}
return gui.WithWaitingStatus(waitingStatus, func() error {
if err := gui.OSCommand.RunCommand(resolvedCommand); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
}
return &types.MenuItem{
LabelColumns: []string{
command.Name,
utils.ColoredString(utils.WithShortSha(resolvedCommand), color.FgCyan),
},
OnPress: onPress,
}
})
return gui.Menu(CreateMenuOptions{
Title: title,
Items: menuItems,
})
// GetDisplayStrings is a function.
func (r *customCommandOption) GetDisplayStrings(isFocused bool) []string {
return []string{r.description}
}
func (gui *Gui) createCustomCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject) error {
return gui.createCommandMenu(customCommands, commandObject, gui.Tr.CustomCommandTitle, gui.Tr.RunningCustomCommandStatus)
}
options := make([]*customCommandOption, len(customCommands)+1)
for i, command := range customCommands {
resolvedCommand := utils.ApplyTemplate(command.Command, commandObject)
func (gui *Gui) createBulkCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject) error {
return gui.createCommandMenu(customCommands, commandObject, gui.Tr.BulkCommandTitle, gui.Tr.RunningBulkCommandStatus)
options[i] = &customCommandOption{
customCommand: command,
description: resolvedCommand,
command: resolvedCommand,
runCommand: true,
attach: command.Attach,
}
}
options[len(options)-1] = &customCommandOption{
description: gui.Tr.Cancel,
runCommand: false,
}
handleMenuPress := func(index int) error {
option := options[index]
if !option.runCommand {
return nil
}
// if we have a command for attaching, we attach and return the subprocess error
if option.customCommand.Attach {
cmd := gui.OSCommand.ExecutableFromString(option.command)
gui.SubProcess = cmd
return gui.Errors.ErrSubProcess
}
return gui.WithWaitingStatus(gui.Tr.RunningCustomCommandStatus, func() error {
if err := gui.OSCommand.RunCommand(option.command); err != nil {
return gui.createErrorPanel(gui.g, err.Error())
}
return nil
})
}
return gui.createMenu("", options, len(options), handleMenuPress)
}

View file

@ -1,80 +0,0 @@
package gui
import (
"fmt"
"github.com/jesseduffield/gocui"
)
func (gui *Gui) handleOpenFilter() error {
panel, ok := gui.currentListPanel()
if !ok {
return nil
}
if panel.IsFilterDisabled() {
return nil
}
gui.State.Filter.active = true
gui.State.Filter.panel = panel
return gui.switchFocus(gui.Views.Filter)
}
func (gui *Gui) onNewFilterNeedle(value string) error {
gui.State.Filter.needle = value
gui.ResetOrigin(gui.State.Filter.panel.GetView())
return gui.State.Filter.panel.RerenderList()
}
func (gui *Gui) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
matched := f(v, key, ch, mod)
if matched {
if err := gui.onNewFilterNeedle(v.TextArea.GetContent()); err != nil {
gui.Log.Error(err)
}
}
return matched
}
}
func (gui *Gui) escapeFilterPrompt() error {
if err := gui.clearFilter(); err != nil {
return err
}
return gui.returnFocus()
}
func (gui *Gui) clearFilter() error {
gui.State.Filter.needle = ""
gui.State.Filter.active = false
panel := gui.State.Filter.panel
gui.State.Filter.panel = nil
gui.Views.Filter.ClearTextArea()
if panel == nil {
return nil
}
gui.ResetOrigin(panel.GetView())
return panel.RerenderList()
}
// returns to the list view with the filter still applied
func (gui *Gui) commitFilter() error {
if gui.State.Filter.needle == "" {
if err := gui.clearFilter(); err != nil {
return err
}
}
return gui.returnFocus()
}
func (gui *Gui) filterPrompt() string {
return fmt.Sprintf("%s: ", gui.Tr.FilterPrompt)
}

View file

@ -1,146 +0,0 @@
package gui
import (
"github.com/jesseduffield/gocui"
"github.com/samber/lo"
)
func (gui *Gui) newLineFocused(v *gocui.View) error {
if v == nil {
return nil
}
currentListPanel, ok := gui.currentListPanel()
if ok {
return currentListPanel.HandleSelect()
}
switch v.Name() {
case "confirmation":
return nil
case "main":
v.Highlight = false
return nil
case "filter":
return nil
default:
panic(gui.Tr.NoViewMachingNewLineFocusedSwitchStatement)
}
}
// TODO: move some of this logic into our onFocusLost and onFocus hooks
func (gui *Gui) switchFocus(newView *gocui.View) error {
gui.Mutexes.ViewStackMutex.Lock()
defer gui.Mutexes.ViewStackMutex.Unlock()
return gui.switchFocusAux(newView)
}
func (gui *Gui) switchFocusAux(newView *gocui.View) error {
gui.pushView(newView.Name())
gui.Log.Info("setting highlight to true for view " + newView.Name())
gui.Log.Info("new focused view is " + newView.Name())
if _, err := gui.g.SetCurrentView(newView.Name()); err != nil {
return err
}
gui.g.Cursor = newView.Editable
if err := gui.renderPanelOptions(); err != nil {
return err
}
newViewStack := gui.State.ViewStack
if gui.State.Filter.panel != nil && !lo.Contains(newViewStack, gui.State.Filter.panel.GetView().Name()) {
if err := gui.clearFilter(); err != nil {
return err
}
}
// TODO: add 'onFocusLost' hook
if !lo.Contains(newViewStack, "menu") {
gui.Views.Menu.Visible = false
}
return gui.newLineFocused(newView)
}
func (gui *Gui) returnFocus() error {
gui.Mutexes.ViewStackMutex.Lock()
defer gui.Mutexes.ViewStackMutex.Unlock()
if len(gui.State.ViewStack) <= 1 {
return nil
}
previousViewName := gui.State.ViewStack[len(gui.State.ViewStack)-2]
previousView, err := gui.g.View(previousViewName)
if err != nil {
return err
}
return gui.switchFocusAux(previousView)
}
func (gui *Gui) removeViewFromStack(view *gocui.View) {
gui.Mutexes.ViewStackMutex.Lock()
defer gui.Mutexes.ViewStackMutex.Unlock()
gui.State.ViewStack = lo.Filter(gui.State.ViewStack, func(viewName string, _ int) bool {
return viewName != view.Name()
})
}
// Not to be called directly. Use `switchFocus` instead
func (gui *Gui) pushView(name string) {
// No matter what view we're pushing, we first remove all popup panels from the stack
// (unless it's the search view because we may be searching the menu panel)
if name != "filter" {
gui.State.ViewStack = lo.Filter(gui.State.ViewStack, func(viewName string, _ int) bool {
return !gui.isPopupPanel(viewName)
})
}
// If we're pushing a side panel, we remove all other panels
if lo.Contains(gui.sideViewNames(), name) {
gui.State.ViewStack = []string{}
}
// If we're pushing a panel that's already in the stack, we remove it
gui.State.ViewStack = lo.Filter(gui.State.ViewStack, func(viewName string, _ int) bool {
return viewName != name
})
gui.State.ViewStack = append(gui.State.ViewStack, name)
}
// excludes popups
func (gui *Gui) currentStaticViewName() string {
gui.Mutexes.ViewStackMutex.Lock()
defer gui.Mutexes.ViewStackMutex.Unlock()
for i := len(gui.State.ViewStack) - 1; i >= 0; i-- {
if !lo.Contains(gui.popupViewNames(), gui.State.ViewStack[i]) {
return gui.State.ViewStack[i]
}
}
return gui.initiallyFocusedViewName()
}
func (gui *Gui) currentSideViewName() string {
gui.Mutexes.ViewStackMutex.Lock()
defer gui.Mutexes.ViewStackMutex.Unlock()
// we expect that there is a side window somewhere in the view stack, so we will search from top to bottom
for idx := range gui.State.ViewStack {
reversedIdx := len(gui.State.ViewStack) - 1 - idx
viewName := gui.State.ViewStack[reversedIdx]
if lo.Contains(gui.sideViewNames(), viewName) {
return viewName
}
}
return gui.initiallyFocusedViewName()
}

View file

@ -1,45 +0,0 @@
package gui
import (
"github.com/gookit/color"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/utils"
)
var gocuiColorMap = map[string]gocui.Attribute{
"default": gocui.ColorDefault,
"black": gocui.ColorBlack,
"red": gocui.ColorRed,
"green": gocui.ColorGreen,
"yellow": gocui.ColorYellow,
"blue": gocui.ColorBlue,
"magenta": gocui.ColorMagenta,
"cyan": gocui.ColorCyan,
"white": gocui.ColorWhite,
"bold": gocui.AttrBold,
"reverse": gocui.AttrReverse,
"underline": gocui.AttrUnderline,
}
// GetAttribute gets the gocui color attribute from the string
func GetGocuiAttribute(key string) gocui.Attribute {
if utils.IsValidHexValue(key) {
values := color.HEX(key).Values()
return gocui.NewRGBColor(int32(values[0]), int32(values[1]), int32(values[2]))
}
value, present := gocuiColorMap[key]
if present {
return value
}
return gocui.ColorDefault
}
// GetGocuiStyle bitwise OR's a list of attributes obtained via the given keys
func GetGocuiStyle(keys []string) gocui.Attribute {
var attribute gocui.Attribute
for _, key := range keys {
attribute |= GetGocuiAttribute(key)
}
return attribute
}

View file

@ -1,168 +1,228 @@
package gui
import (
"context"
"os"
"strings"
"time"
"sync"
"github.com/docker/docker/api/types/events"
// "io"
// "io/ioutil"
"os/exec"
"time"
"github.com/go-errors/errors"
throttle "github.com/boz/go-throttle"
// "strings"
"github.com/jesseduffield/gocui"
lcUtils "github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/gui/panels"
"github.com/jesseduffield/lazydocker/pkg/gui/types"
"github.com/jesseduffield/lazydocker/pkg/i18n"
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
// OverlappingEdges determines if panel edges overlap
var OverlappingEdges = false
// SentinelErrors are the errors that have special meaning and need to be checked
// by calling functions. The less of these, the better
type SentinelErrors struct {
ErrSubProcess error
ErrNoContainers error
ErrNoImages error
ErrNoVolumes error
}
// GenerateSentinelErrors makes the sentinel errors for the gui. We're defining it here
// because we can't do package-scoped errors with localization, and also because
// it seems like package-scoped variables are bad in general
// https://dave.cheney.net/2017/06/11/go-without-package-scoped-variables
// In the future it would be good to implement some of the recommendations of
// that article. For now, if we don't need an error to be a sentinel, we will just
// define it inline. This has implications for error messages that pop up everywhere
// in that we'll be duplicating the default values. We may need to look at
// having a default localisation bundle defined, and just using keys-only when
// localising things in the code.
func (gui *Gui) GenerateSentinelErrors() {
gui.Errors = SentinelErrors{
ErrSubProcess: errors.New(gui.Tr.RunningSubprocess),
ErrNoContainers: errors.New(gui.Tr.NoContainers),
ErrNoImages: errors.New(gui.Tr.NoImages),
ErrNoVolumes: errors.New(gui.Tr.NoVolumes),
}
}
// Gui wraps the gocui Gui object which handles rendering and events
type Gui struct {
g *gocui.Gui
Log *logrus.Entry
DockerCommand *commands.DockerCommand
OSCommand *commands.OSCommand
SubProcess *exec.Cmd
State guiState
Config *config.AppConfig
Tr *i18n.TranslationSet
Errors SentinelErrors
statusManager *statusManager
taskManager *tasks.TaskManager
waitForIntro sync.WaitGroup
T *tasks.TaskManager
ErrorChan chan error
Views Views
// if we've suspended the gui (e.g. because we've switched to a subprocess)
// we typically want to pause some things that are running like background
// file refreshes
PauseBackgroundThreads bool
Mutexes
Panels Panels
CyclableViews []string
}
type Panels struct {
Projects *panels.SideListPanel[*commands.Project]
Services *panels.SideListPanel[*commands.Service]
Containers *panels.SideListPanel[*commands.Container]
Images *panels.SideListPanel[*commands.Image]
Volumes *panels.SideListPanel[*commands.Volume]
Networks *panels.SideListPanel[*commands.Network]
Menu *panels.SideListPanel[*types.MenuItem]
type servicePanelState struct {
SelectedLine int
ContextIndex int // for specifying if you are looking at logs/stats/config/etc
}
type Mutexes struct {
SubprocessMutex deadlock.Mutex
ViewStackMutex deadlock.Mutex
type containerPanelState struct {
SelectedLine int
ContextIndex int // for specifying if you are looking at logs/stats/config/etc
}
type statusState struct {
ContextIndex int // for specifying if you are looking at credits/logs
}
type menuPanelState struct {
SelectedLine int
}
type mainPanelState struct {
// ObjectKey tells us what context we are in. For example, if we are looking at the logs of a particular service in the services panel this key might be 'services-<service id>-logs'. The key is made so that if something changes which might require us to re-run the logs command or run a different command, the key will be different, and we'll then know to do whatever is required. Object key probably isn't the best name for this but Context is already used to refer to tabs. Maybe I should just call them tabs.
ObjectKey string
}
type imagePanelState struct {
SelectedLine int
ContextIndex int // for specifying if you are looking at logs/stats/config/etc
}
type volumePanelState struct {
SelectedLine int
ContextIndex int
}
type panelStates struct {
Main *mainPanelState
Services *servicePanelState
Containers *containerPanelState
Menu *menuPanelState
Main *mainPanelState
Images *imagePanelState
Volumes *volumePanelState
Status *statusState
}
type guiState struct {
// the names of views in the current focus stack (last item is the current view)
ViewStack []string
MenuItemCount int // can't store the actual list because it's of interface{} type
PreviousView string
Platform commands.Platform
Panels *panelStates
SubProcessOutput string
Stats map[string]commands.ContainerStats
// if true, we show containers with an 'exited' status in the containers panel
ShowExitedContainers bool
ScreenMode WindowMaximisation
// Maintains the state of manual filtering i.e. typing in a substring
// to filter on in the current panel.
Filter filterState
}
type filterState struct {
// If true then we're either currently inside the filter view
// or we've committed the filter and we're back in the list view
active bool
// The panel that we're filtering.
panel panels.ISideListPanel
// The string that we're filtering on
needle string
}
// screen sizing determines how much space your selected window takes up (window
// as in panel, not your terminal's window). Sometimes you want a bit more space
// to see the contents of a panel, and this keeps track of how much maximisation
// you've set
type WindowMaximisation int
const (
SCREEN_NORMAL WindowMaximisation = iota
SCREEN_HALF
SCREEN_FULL
)
func getScreenMode(config *config.AppConfig) WindowMaximisation {
switch config.UserConfig.Gui.ScreenMode {
case "normal":
return SCREEN_NORMAL
case "half":
return SCREEN_HALF
case "fullscreen":
return SCREEN_FULL
default:
return SCREEN_NORMAL
}
}
// NewGui builds a new gui handler
func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand *commands.OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*Gui, error) {
previousView := "containers"
if dockerCommand.InDockerComposeProject {
previousView = "services"
}
initialState := guiState{
Platform: *oSCommand.Platform,
PreviousView: previousView,
Platform: *oSCommand.Platform,
Panels: &panelStates{
Services: &servicePanelState{SelectedLine: -1, ContextIndex: 0},
Containers: &containerPanelState{SelectedLine: -1, ContextIndex: 0},
Images: &imagePanelState{SelectedLine: -1, ContextIndex: 0},
Volumes: &volumePanelState{SelectedLine: -1, ContextIndex: 0},
Menu: &menuPanelState{SelectedLine: 0},
Main: &mainPanelState{
ObjectKey: "",
},
Status: &statusState{ContextIndex: 0},
},
ViewStack: []string{},
}
ShowExitedContainers: true,
ScreenMode: getScreenMode(config),
cyclableViews := []string{"status", "containers", "images", "volumes"}
if dockerCommand.InDockerComposeProject {
cyclableViews = []string{"status", "services", "containers", "images", "volumes"}
}
gui := &Gui{
Log: log,
DockerCommand: dockerCommand,
OSCommand: oSCommand,
// TODO: look into this warning
State: initialState,
Config: config,
Tr: tr,
statusManager: &statusManager{},
taskManager: tasks.NewTaskManager(log, tr),
T: tasks.NewTaskManager(log),
ErrorChan: errorChan,
CyclableViews: cyclableViews,
}
deadlock.Opts.Disable = !gui.Config.Debug
deadlock.Opts.DeadlockTimeout = 10 * time.Second
gui.GenerateSentinelErrors()
return gui, nil
}
func (gui *Gui) handleRefresh(g *gocui.Gui, v *gocui.View) error {
return gui.refreshSidePanels(g)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func (gui *Gui) loadNewDirectory() error {
gui.waitForIntro.Done()
if err := gui.refreshSidePanels(gui.g); err != nil {
return err
}
if gui.Config.UserConfig.Reporting == "undetermined" {
if err := gui.promptAnonymousReporting(); err != nil {
return err
}
}
return nil
}
func (gui *Gui) promptAnonymousReporting() error {
return gui.createConfirmationPanel(gui.g, nil, gui.Tr.AnonymousReportingTitle, gui.Tr.AnonymousReportingPrompt, func(g *gocui.Gui, v *gocui.View) error {
gui.waitForIntro.Done()
return gui.Config.WriteToUserConfig(func(userConfig *config.UserConfig) error {
userConfig.Reporting = "on"
return nil
})
}, func(g *gocui.Gui, v *gocui.View) error {
gui.waitForIntro.Done()
return gui.Config.WriteToUserConfig(func(userConfig *config.UserConfig) error {
userConfig.Reporting = "off"
return nil
})
})
}
func (gui *Gui) renderAppStatus() error {
appStatus := gui.statusManager.getStatusString()
if appStatus != "" {
return gui.renderString(gui.g, "appStatus", appStatus)
}
return nil
}
func (gui *Gui) renderGlobalOptions() error {
return gui.renderOptionsMap(map[string]string{
"PgUp/PgDn": gui.Tr.Scroll,
"← → ↑ ↓": gui.Tr.Navigate,
"q": gui.Tr.Quit,
"b": gui.Tr.ViewBulkCommands,
"esc/q": gui.Tr.Close,
"x": gui.Tr.Menu,
})
}
@ -170,220 +230,65 @@ func (gui *Gui) renderGlobalOptions() error {
func (gui *Gui) goEvery(interval time.Duration, function func() error) {
_ = function() // time.Tick doesn't run immediately so we'll do that here // TODO: maybe change
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if !gui.PauseBackgroundThreads {
_ = function()
}
for range time.Tick(interval) {
_ = function()
}
}()
}
// Run setup the gui with keybindings and start the mainloop
func (gui *Gui) Run() error {
// closing our task manager which in turn closes the current task if there is any, so we aren't leaving processes lying around after closing lazydocker
defer gui.taskManager.Close()
g, err := gocui.NewGui(gocui.NewGuiOpts{
OutputMode: gocui.OutputTrue,
RuneReplacements: map[rune]string{},
})
g, err := gocui.NewGui(gocui.OutputNormal, OverlappingEdges)
if err != nil {
return err
}
defer g.Close()
// forgive the double-negative, this is because of my yaml `omitempty` woes
if !gui.Config.UserConfig.Gui.IgnoreMouseEvents {
if gui.Config.UserConfig.Gui.MouseEvents {
g.Mouse = true
}
gui.g = g // TODO: always use gui.g rather than passing g around everywhere
// if the deadlock package wants to report a deadlock, we first need to
// close the gui so that we can actually read what it prints.
deadlock.Opts.LogBuf = lcUtils.NewOnceWriter(os.Stderr, func() {
gui.g.Close()
})
if err := gui.SetColorScheme(); err != nil {
return err
}
throttledRefresh := throttle.ThrottleFunc(time.Millisecond*50, true, gui.refresh)
defer throttledRefresh.Stop()
if gui.Config.UserConfig.Reporting == "undetermined" {
gui.waitForIntro.Add(2)
} else {
gui.waitForIntro.Add(1)
}
go func() {
gui.waitForIntro.Wait()
gui.goEvery(time.Millisecond*50, gui.renderAppStatus)
gui.goEvery(time.Millisecond*30, gui.reRenderMain)
gui.goEvery(time.Millisecond*100, gui.refreshContainersAndServices)
gui.goEvery(time.Millisecond*100, gui.refreshVolumes)
gui.goEvery(time.Millisecond*1000, gui.DockerCommand.UpdateContainerDetails)
}()
go gui.DockerCommand.MonitorContainerStats()
go func() {
for err := range gui.ErrorChan {
if err == nil {
continue
}
if strings.Contains(err.Error(), "No such container") {
// this happens all the time when e.g. restarting containers so we won't worry about it
gui.Log.Warn(err)
continue
}
_ = gui.createErrorPanel(err.Error())
gui.createErrorPanel(gui.g, err.Error())
}
}()
g.SetManager(gocui.ManagerFunc(gui.layout), gocui.ManagerFunc(gui.getFocusLayout()))
if err := gui.createAllViews(); err != nil {
return err
}
if err := gui.setInitialViewContent(); err != nil {
return err
}
// TODO: see if we can avoid the circular dependency
gui.setPanels()
if err = gui.keybindings(g); err != nil {
return err
}
if gui.g.CurrentView() == nil {
viewName := gui.initiallyFocusedViewName()
view, err := gui.g.View(viewName)
if err != nil {
return err
}
if err := gui.switchFocus(view); err != nil {
return err
}
}
ctx, finish := context.WithCancel(context.Background())
defer finish()
go gui.listenForEvents(ctx, throttledRefresh.Trigger)
go gui.monitorContainerStats(ctx)
go func() {
throttledRefresh.Trigger()
gui.goEvery(time.Millisecond*30, gui.reRenderMain)
gui.goEvery(time.Millisecond*1000, gui.updateContainerDetails)
gui.goEvery(time.Millisecond*1000, gui.checkForContextChange)
// we need to regularly re-render these because their stats will be changed in the background
gui.goEvery(time.Millisecond*1000, gui.renderContainersAndServices)
}()
err = g.MainLoop()
if err == gocui.ErrQuit {
return nil
}
return err
}
func (gui *Gui) setPanels() {
gui.Panels = Panels{
Projects: gui.getProjectPanel(),
Services: gui.getServicesPanel(),
Containers: gui.getContainersPanel(),
Images: gui.getImagesPanel(),
Volumes: gui.getVolumesPanel(),
Networks: gui.getNetworksPanel(),
Menu: gui.getMenuPanel(),
}
}
func (gui *Gui) updateContainerDetails() error {
return gui.DockerCommand.RefreshContainerDetails(gui.Panels.Containers.List.GetAllItems())
}
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 {
gui.Log.Error(err)
}
if err := gui.refreshProject(); err != nil {
gui.Log.Error(err)
}
}()
go func() {
if err := gui.reloadVolumes(); err != nil {
gui.Log.Error(err)
}
}()
go func() {
if err := gui.reloadNetworks(); err != nil {
gui.Log.Error(err)
}
}()
go func() {
if err := gui.reloadImages(); err != nil {
gui.Log.Error(err)
}
}()
}
func (gui *Gui) listenForEvents(ctx context.Context, refresh func()) {
errorCount := 0
onError := func(err error) {
if err != nil {
gui.ErrorChan <- errors.Errorf("Docker event stream returned error: %s\nRetry count: %d", err.Error(), errorCount)
}
errorCount++
time.Sleep(time.Second * 2)
}
outer:
for {
messageChan, errChan := gui.DockerCommand.Client.Events(context.Background(), events.ListOptions{})
if errorCount > 0 {
select {
case <-ctx.Done():
return
case err := <-errChan:
onError(err)
continue outer
default:
// If we're here then we lost connection to docker and we just got it back.
// The reason we do this refresh explicitly is because successfully
// reconnecting with docker does not mean it's going to send us a new
// event any time soon.
// Assuming the confirmation prompt currently holds the given error
_ = gui.closeConfirmationPrompt()
refresh()
errorCount = 0
}
}
for {
select {
case <-ctx.Done():
return
case message := <-messageChan:
// We could be more granular about what events should trigger which refreshes.
// At the moment it's pretty efficient though, and it might not be worth
// the maintenance burden of mapping specific events to specific refreshes
refresh()
gui.Log.Infof("received event of type: %s", message.Type)
case err := <-errChan:
onError(err)
continue outer
}
}
}
}
// checkForContextChange runs the currently focused panel's 'select' function, simulating the current item having just been selected. This will then trigger a check to see if anything's changed (e.g. a service has a new container) and if so, the appropriate code will run. For example, if you're reading logs from a service and all of a sudden its container changes, this will trigger the 'select' function, which will work out that the context is not different because of the new container, and then it will re-attempt to get the logs, this time for the correct container. This 'context' is stored in the main panel's ObjectKey. I'm using the term 'context' here more broadly than just the different tabs you can view in a panel.
func (gui *Gui) checkForContextChange() error {
return gui.newLineFocused(gui.g.CurrentView())
}
func (gui *Gui) reRenderMain() error {
mainView := gui.Views.Main
mainView := gui.getMainView()
if mainView == nil {
return nil
}
@ -397,23 +302,13 @@ func (gui *Gui) reRenderMain() error {
func (gui *Gui) quit(g *gocui.Gui, v *gocui.View) error {
if gui.Config.UserConfig.ConfirmOnQuit {
return gui.createConfirmationPanel("", gui.Tr.ConfirmQuit, func(g *gocui.Gui, v *gocui.View) error {
return gui.createConfirmationPanel(g, v, "", gui.Tr.ConfirmQuit, func(g *gocui.Gui, v *gocui.View) error {
return gocui.ErrQuit
}, nil)
}
return gocui.ErrQuit
}
// this handler is executed when we press escape when there is only one view
// on the stack.
func (gui *Gui) escape() error {
if gui.State.Filter.active {
return gui.clearFilter()
}
return nil
}
func (gui *Gui) handleDonate(g *gocui.Gui, v *gocui.View) error {
if !gui.g.Mouse {
return nil
@ -423,92 +318,42 @@ func (gui *Gui) handleDonate(g *gocui.Gui, v *gocui.View) error {
if cx > len(gui.Tr.Donate) {
return nil
}
return gui.OSCommand.OpenLink("https://github.com/sponsors/jesseduffield")
return gui.OSCommand.OpenLink("https://donorbox.org/lazydocker")
}
func (gui *Gui) editFile(filename string) error {
cmd, err := gui.OSCommand.EditFile(filename)
if err != nil {
return gui.createErrorPanel(err.Error())
}
return gui.runSubprocess(cmd)
_, err := gui.runSyncOrAsyncCommand(gui.OSCommand.EditFile(filename))
return err
}
func (gui *Gui) openFile(filename string) error {
if err := gui.OSCommand.OpenFile(filename); err != nil {
return gui.createErrorPanel(err.Error())
return gui.createErrorPanel(gui.g, err.Error())
}
return nil
}
func (gui *Gui) handleCustomCommand(g *gocui.Gui, v *gocui.View) error {
return gui.createPromptPanel(gui.Tr.CustomCommandTitle, func(g *gocui.Gui, v *gocui.View) error {
command := gui.trimmedContent(v)
return gui.runSubprocess(gui.OSCommand.RunCustomCommand(command))
})
}
func (gui *Gui) ShouldRefresh(key string) bool {
if gui.State.Panels.Main.ObjectKey == key {
return false
}
gui.State.Panels.Main.ObjectKey = key
return true
}
func (gui *Gui) initiallyFocusedViewName() string {
if gui.DockerCommand.IsProjectScoped() {
return "services"
}
return "containers"
}
func (gui *Gui) IgnoreStrings() []string {
return gui.Config.UserConfig.Ignore
}
func (gui *Gui) Update(f func() error) {
gui.g.Update(func(*gocui.Gui) error { return f() })
}
func (gui *Gui) monitorContainerStats(ctx context.Context) {
// periodically loop through running containers and see if we need to create a monitor goroutine for any
// every second we check if we need to spawn a new goroutine
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, container := range gui.Panels.Containers.List.GetAllItems() {
if !container.MonitoringStats {
go gui.DockerCommand.CreateClientStatMonitor(container)
}
}
// runSyncOrAsyncCommand takes the output of a command that may have returned
// either no error, an error, or a subprocess to execute, and if a subprocess
// needs to be set on the gui object, it does so, and then returns the error
// the bool returned tells us whether the calling code should continue
func (gui *Gui) runSyncOrAsyncCommand(sub *exec.Cmd, err error) (bool, error) {
if err != nil {
if err != gui.Errors.ErrSubProcess {
return false, gui.createErrorPanel(gui.g, err.Error())
}
}
if sub != nil {
gui.SubProcess = sub
return false, gui.Errors.ErrSubProcess
}
return true, nil
}
// this is used by our cheatsheet code to generate keybindings. We need some views
// and panels to exist for us to know what keybindings there are, so we invoke
// gocui in headless mode and create them.
func (gui *Gui) SetupFakeGui() {
g, err := gocui.NewGui(gocui.NewGuiOpts{
OutputMode: gocui.OutputTrue,
RuneReplacements: map[rune]string{},
Headless: true,
func (gui *Gui) handleCustomCommand(g *gocui.Gui, v *gocui.View) error {
return gui.createPromptPanel(g, v, gui.Tr.CustomCommandTitle, func(g *gocui.Gui, v *gocui.View) error {
command := gui.trimmedContent(v)
gui.SubProcess = gui.OSCommand.RunCustomCommand(command)
return gui.Errors.ErrSubProcess
})
if err != nil {
panic(err)
}
gui.g = g
defer g.Close()
if err := gui.createAllViews(); err != nil {
panic(err)
}
gui.setPanels()
}

View file

@ -5,217 +5,277 @@ import (
"strings"
"time"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types"
"github.com/fatih/color"
"github.com/go-errors/errors"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/gui/panels"
"github.com/jesseduffield/lazydocker/pkg/gui/presentation"
"github.com/jesseduffield/lazydocker/pkg/gui/types"
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
)
func (gui *Gui) getImagesPanel() *panels.SideListPanel[*commands.Image] {
noneLabel := "<none>"
// list panel functions
return &panels.SideListPanel[*commands.Image]{
ContextState: &panels.ContextState[*commands.Image]{
GetMainTabs: func() []panels.MainTab[*commands.Image] {
return []panels.MainTab[*commands.Image]{
{
Key: "config",
Title: gui.Tr.ConfigTitle,
Render: gui.renderImageConfigTask,
},
}
},
GetItemContextCacheKey: func(image *commands.Image) string {
return "images-" + image.ID
},
},
ListPanel: panels.ListPanel[*commands.Image]{
List: panels.NewFilteredList[*commands.Image](),
View: gui.Views.Images,
},
NoItemsMessage: gui.Tr.NoImages,
Gui: gui.intoInterface(),
Sort: func(a *commands.Image, b *commands.Image) bool {
if a.Name == noneLabel && b.Name != noneLabel {
return false
}
if a.Name != noneLabel && b.Name == noneLabel {
return true
}
if a.Name != b.Name {
return a.Name < b.Name
}
if a.Tag != b.Tag {
return a.Tag < b.Tag
}
return a.ID < b.ID
},
GetTableCells: presentation.GetImageDisplayStrings,
}
func (gui *Gui) getImageContexts() []string {
return []string{"config"}
}
func (gui *Gui) renderImageConfigTask(image *commands.Image) tasks.TaskFunc {
return gui.NewRenderStringTask(RenderStringTaskOpts{
GetStrContent: func() string { return gui.imageConfigStr(image) },
Autoscroll: false,
Wrap: false, // don't care what your config is this page is ugly without wrapping
})
func (gui *Gui) getImageContextTitles() []string {
return []string{gui.Tr.ConfigTitle}
}
func (gui *Gui) imageConfigStr(image *commands.Image) string {
padding := 10
output := ""
output += utils.WithPadding("Name: ", padding) + image.Name + "\n"
output += utils.WithPadding("ID: ", padding) + image.Image.ID + "\n"
output += utils.WithPadding("Tags: ", padding) + utils.ColoredString(strings.Join(image.Image.RepoTags, ", "), color.FgGreen) + "\n"
output += utils.WithPadding("Size: ", padding) + utils.FormatDecimalBytes(int(image.Image.Size)) + "\n"
output += utils.WithPadding("Created: ", padding) + fmt.Sprintf("%v", time.Unix(image.Image.Created, 0).Format(time.RFC1123)) + "\n"
history, err := image.RenderHistory()
if err != nil {
gui.Log.Error(err)
func (gui *Gui) getSelectedImage(g *gocui.Gui) (*commands.Image, error) {
selectedLine := gui.State.Panels.Images.SelectedLine
if selectedLine == -1 {
return &commands.Image{}, gui.Errors.ErrNoImages
}
output += "\n\n" + history
return output
return gui.DockerCommand.Images[selectedLine], nil
}
func (gui *Gui) reloadImages() error {
if err := gui.refreshStateImages(); err != nil {
return err
func (gui *Gui) handleImagesFocus(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() {
return nil
}
return gui.Panels.Images.RerenderList()
}
cx, cy := v.Cursor()
_, oy := v.Origin()
func (gui *Gui) refreshStateImages() error {
images, err := gui.DockerCommand.RefreshImages()
if err != nil {
return err
prevSelectedLine := gui.State.Panels.Images.SelectedLine
newSelectedLine := cy - oy
if newSelectedLine > len(gui.DockerCommand.Images)-1 || len(utils.Decolorise(gui.DockerCommand.Images[newSelectedLine].Name)) < cx {
return gui.handleImageSelect(gui.g, v)
}
gui.Panels.Images.SetItems(images)
gui.State.Panels.Images.SelectedLine = newSelectedLine
if !(prevSelectedLine == newSelectedLine && gui.currentViewName() == v.Name()) {
return gui.handleImageSelect(gui.g, v)
}
return nil
}
func (gui *Gui) FilterString(view *gocui.View) string {
if gui.State.Filter.panel != nil && gui.State.Filter.panel.GetView() != view {
return ""
func (gui *Gui) handleImageSelect(g *gocui.Gui, v *gocui.View) error {
if _, err := gui.g.SetCurrentView(v.Name()); err != nil {
return err
}
return gui.State.Filter.needle
Image, err := gui.getSelectedImage(g)
if err != nil {
if err != gui.Errors.ErrNoImages {
return err
}
return gui.renderString(g, "main", gui.Tr.NoImages)
}
key := "images-" + Image.ID + "-" + gui.getImageContexts()[gui.State.Panels.Images.ContextIndex]
if gui.State.Panels.Main.ObjectKey == key {
return nil
} else {
gui.State.Panels.Main.ObjectKey = key
}
if err := gui.focusPoint(0, gui.State.Panels.Images.SelectedLine, len(gui.DockerCommand.Images), v); err != nil {
return err
}
mainView := gui.getMainView()
mainView.Tabs = gui.getImageContextTitles()
mainView.TabIndex = gui.State.Panels.Images.ContextIndex
switch gui.getImageContexts()[gui.State.Panels.Images.ContextIndex] {
case "config":
if err := gui.renderImageConfig(mainView, Image); err != nil {
return err
}
default:
return errors.New("Unknown context for Images panel")
}
return nil
}
func (gui *Gui) renderImageConfig(mainView *gocui.View, image *commands.Image) error {
return gui.T.NewTask(func(stop chan struct{}) {
padding := 10
output := ""
output += utils.WithPadding("Name: ", padding) + image.Name + "\n"
output += utils.WithPadding("ID: ", padding) + image.Image.ID + "\n"
output += utils.WithPadding("Tags: ", padding) + utils.ColoredString(strings.Join(image.Image.RepoTags, ", "), color.FgGreen) + "\n"
output += utils.WithPadding("Size: ", padding) + utils.FormatDecimalBytes(int(image.Image.Size)) + "\n"
output += utils.WithPadding("Created: ", padding) + fmt.Sprintf("%v", time.Unix(image.Image.Created, 0).Format(time.RFC1123)) + "\n"
history, err := image.RenderHistory()
if err != nil {
gui.Log.Error(err)
}
output += "\n\n" + history
mainView.Autoscroll = false
mainView.Wrap = false
gui.renderString(gui.g, "main", output)
})
}
func (gui *Gui) refreshImages() error {
ImagesView := gui.getImagesView()
if ImagesView == nil {
// if the ImagesView hasn't been instantiated yet we just return
return nil
}
if err := gui.refreshStateImages(); err != nil {
return err
}
if len(gui.DockerCommand.Images) > 0 && gui.State.Panels.Images.SelectedLine == -1 {
gui.State.Panels.Images.SelectedLine = 0
}
if len(gui.DockerCommand.Images)-1 < gui.State.Panels.Images.SelectedLine {
gui.State.Panels.Images.SelectedLine = len(gui.DockerCommand.Images) - 1
}
gui.g.Update(func(g *gocui.Gui) error {
ImagesView.Clear()
isFocused := gui.g.CurrentView().Name() == "Images"
list, err := utils.RenderList(gui.DockerCommand.Images, utils.IsFocused(isFocused))
if err != nil {
return err
}
fmt.Fprint(ImagesView, list)
if ImagesView == g.CurrentView() {
return gui.handleImageSelect(g, ImagesView)
}
return nil
})
return nil
}
// TODO: leave this to DockerCommand
func (gui *Gui) refreshStateImages() error {
Images, err := gui.DockerCommand.GetImages()
if err != nil {
return err
}
gui.DockerCommand.Images = Images
return nil
}
func (gui *Gui) handleImagesNextLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() {
return nil
}
panelState := gui.State.Panels.Images
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.Images), false)
return gui.handleImageSelect(gui.g, v)
}
func (gui *Gui) handleImagesPrevLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() {
return nil
}
panelState := gui.State.Panels.Images
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.Images), true)
return gui.handleImageSelect(gui.g, v)
}
func (gui *Gui) handleImagesNextContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getImageContexts()
if gui.State.Panels.Images.ContextIndex >= len(contexts)-1 {
gui.State.Panels.Images.ContextIndex = 0
} else {
gui.State.Panels.Images.ContextIndex++
}
gui.handleImageSelect(gui.g, v)
return nil
}
func (gui *Gui) handleImagesPrevContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getImageContexts()
if gui.State.Panels.Images.ContextIndex <= 0 {
gui.State.Panels.Images.ContextIndex = len(contexts) - 1
} else {
gui.State.Panels.Images.ContextIndex--
}
gui.handleImageSelect(gui.g, v)
return nil
}
type removeImageOption struct {
description string
command string
configOptions types.ImageRemoveOptions
runCommand bool
}
// GetDisplayStrings is a function.
func (r *removeImageOption) GetDisplayStrings(isFocused bool) []string {
return []string{r.description, color.New(color.FgRed).Sprint(r.command)}
}
func (gui *Gui) handleImagesRemoveMenu(g *gocui.Gui, v *gocui.View) error {
type removeImageOption struct {
description string
command string
configOptions image.RemoveOptions
}
img, err := gui.Panels.Images.GetSelectedItem()
Image, err := gui.getSelectedImage(g)
if err != nil {
return nil
}
shortSha := img.ID[7:17]
// TODO: have a way of toggling in a menu instead of showing each permutation as a separate menu item
options := []*removeImageOption{
{
description: gui.Tr.Remove,
command: "docker image rm " + shortSha,
configOptions: image.RemoveOptions{PruneChildren: true, Force: false},
command: "docker image rm " + Image.ID[1:20],
configOptions: types.ImageRemoveOptions{PruneChildren: true},
runCommand: true,
},
{
description: gui.Tr.RemoveWithoutPrune,
command: "docker image rm --no-prune " + shortSha,
configOptions: image.RemoveOptions{PruneChildren: false, Force: false},
command: "docker image rm --no-prune " + Image.ID[1:20],
configOptions: types.ImageRemoveOptions{PruneChildren: false},
runCommand: true,
},
{
description: gui.Tr.RemoveWithForce,
command: "docker image rm --force " + shortSha,
configOptions: image.RemoveOptions{PruneChildren: true, Force: true},
},
{
description: gui.Tr.RemoveWithoutPruneWithForce,
command: "docker image rm --no-prune --force " + shortSha,
configOptions: image.RemoveOptions{PruneChildren: false, Force: true},
description: gui.Tr.Cancel,
runCommand: false,
},
}
menuItems := lo.Map(options, func(option *removeImageOption, _ int) *types.MenuItem {
return &types.MenuItem{
LabelColumns: []string{
option.description,
color.New(color.FgRed).Sprint(option.command),
},
OnPress: func() error {
if err := img.Remove(option.configOptions); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
},
handleMenuPress := func(index int) error {
if !options[index].runCommand {
return nil
}
configOptions := options[index].configOptions
if cerr := Image.Remove(configOptions); cerr != nil {
return gui.createErrorPanel(gui.g, cerr.Error())
}
})
return gui.Menu(CreateMenuOptions{
Title: "",
Items: menuItems,
})
return gui.refreshImages()
}
return gui.createMenu("", options, len(options), handleMenuPress)
}
func (gui *Gui) handlePruneImages() error {
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmPruneImages, func(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handlePruneImages(g *gocui.Gui, v *gocui.View) error {
return gui.createConfirmationPanel(gui.g, v, gui.Tr.Confirm, gui.Tr.ConfirmPruneImages, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.PruningStatus, func() error {
err := gui.DockerCommand.PruneImages()
if err != nil {
return gui.createErrorPanel(err.Error())
return gui.createErrorPanel(gui.g, err.Error())
}
return gui.reloadImages()
return gui.refreshImages()
})
}, nil)
}
func (gui *Gui) handleImagesCustomCommand(g *gocui.Gui, v *gocui.View) error {
img, err := gui.Panels.Images.GetSelectedItem()
if err != nil {
return nil
}
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{
Image: img,
})
customCommands := gui.Config.UserConfig.CustomCommands.Images
return gui.createCustomCommandMenu(customCommands, commandObject)
}
func (gui *Gui) handleImagesBulkCommand(g *gocui.Gui, v *gocui.View) error {
baseBulkCommands := []config.CustomCommand{
{
Name: gui.Tr.PruneImages,
InternalFunction: gui.handlePruneImages,
},
}
bulkCommands := append(baseBulkCommands, gui.Config.UserConfig.BulkCommands.Images...)
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{})
return gui.createBulkCommandMenu(bulkCommands, commandObject)
}

View file

@ -1,8 +1,6 @@
package gui
import (
"fmt"
"github.com/jesseduffield/gocui"
)
@ -17,6 +15,11 @@ type Binding struct {
Description string
}
// GetDisplayStrings returns the display string of a file
func (b *Binding) GetDisplayStrings(isFocused bool) []string {
return []string{b.GetKey(), b.Description}
}
// GetKey is a function.
func (b *Binding) GetKey() string {
key := 0
@ -50,18 +53,12 @@ func (b *Binding) GetKey() string {
return "PgDn"
}
return fmt.Sprintf("%c", key)
return string(key)
}
// GetInitialKeybindings is a function.
func (gui *Gui) GetInitialKeybindings() []*Binding {
bindings := []*Binding{
{
ViewName: "",
Key: gocui.KeyEsc,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.escape),
},
{
ViewName: "",
Key: 'q',
@ -74,29 +71,35 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Modifier: gocui.ModNone,
Handler: gui.quit,
},
{
ViewName: "",
Key: gocui.KeyEsc,
Modifier: gocui.ModNone,
Handler: gui.quit,
},
{
ViewName: "",
Key: gocui.KeyPgup,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.scrollUpMain),
Handler: gui.scrollUpMain,
},
{
ViewName: "",
Key: gocui.KeyPgdn,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.scrollDownMain),
Handler: gui.scrollDownMain,
},
{
ViewName: "",
Key: gocui.KeyCtrlU,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.scrollUpMain),
Handler: gui.scrollUpMain,
},
{
ViewName: "",
Key: gocui.KeyCtrlD,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.scrollDownMain),
Handler: gui.scrollDownMain,
},
{
ViewName: "",
@ -104,24 +107,12 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Modifier: gocui.ModNone,
Handler: gui.autoScrollMain,
},
{
ViewName: "",
Key: gocui.KeyHome,
Modifier: gocui.ModNone,
Handler: gui.jumpToTopMain,
},
{
ViewName: "",
Key: 'x',
Modifier: gocui.ModNone,
Handler: gui.handleCreateOptionsMenu,
},
{
ViewName: "",
Key: '?',
Modifier: gocui.ModNone,
Handler: gui.handleCreateOptionsMenu,
},
{
ViewName: "",
Key: 'X',
@ -129,21 +120,35 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleCustomCommand,
},
{
ViewName: "project",
ViewName: "status",
Key: 'e',
Modifier: gocui.ModNone,
Handler: gui.handleEditConfig,
Description: gui.Tr.EditConfig,
},
{
ViewName: "project",
ViewName: "status",
Key: 'o',
Modifier: gocui.ModNone,
Handler: gui.handleOpenConfig,
Description: gui.Tr.OpenConfig,
},
{
ViewName: "project",
ViewName: "status",
Key: '[',
Modifier: gocui.ModNone,
Handler: gui.handleStatusPrevContext,
Description: gui.Tr.PreviousContext,
},
{
ViewName: "status",
Key: ']',
Modifier: gocui.ModNone,
Handler: gui.handleStatusNextContext,
Description: gui.Tr.NextContext,
},
{
ViewName: "status",
Key: 'm',
Modifier: gocui.ModNone,
Handler: gui.handleViewAllLogs,
@ -153,31 +158,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
ViewName: "menu",
Key: gocui.KeyEsc,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.handleMenuClose),
Handler: gui.handleMenuClose,
},
{
ViewName: "menu",
Key: 'q',
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.handleMenuClose),
},
{
ViewName: "menu",
Key: ' ',
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.handleMenuPress),
},
{
ViewName: "menu",
Key: gocui.KeyEnter,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.handleMenuPress),
},
{
ViewName: "menu",
Key: 'y',
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.handleMenuPress),
Handler: gui.handleMenuClose,
},
{
ViewName: "information",
@ -185,6 +172,20 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Modifier: gocui.ModNone,
Handler: gui.handleDonate,
},
{
ViewName: "containers",
Key: '[',
Modifier: gocui.ModNone,
Handler: gui.handleContainersPrevContext,
Description: gui.Tr.PreviousContext,
},
{
ViewName: "containers",
Key: ']',
Modifier: gocui.ModNone,
Handler: gui.handleContainersNextContext,
Description: gui.Tr.NextContext,
},
{
ViewName: "containers",
Key: 'd',
@ -192,20 +193,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleContainersRemoveMenu,
Description: gui.Tr.Remove,
},
{
ViewName: "containers",
Key: 'e',
Modifier: gocui.ModNone,
Handler: gui.handleHideStoppedContainers,
Description: gui.Tr.HideStopped,
},
{
ViewName: "containers",
Key: 'p',
Modifier: gocui.ModNone,
Handler: gui.handleContainerPause,
Description: gui.Tr.Pause,
},
{
ViewName: "containers",
Key: 's',
@ -227,6 +214,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleContainerAttach,
Description: gui.Tr.Attach,
},
{
ViewName: "containers",
Key: 'D',
Modifier: gocui.ModNone,
Handler: gui.handlePruneContainers,
Description: gui.Tr.PruneContainers,
},
{
ViewName: "containers",
Key: 'm',
@ -234,13 +228,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleContainerViewLogs,
Description: gui.Tr.ViewLogs,
},
{
ViewName: "containers",
Key: 'E',
Modifier: gocui.ModNone,
Handler: gui.handleContainersExecShell,
Description: gui.Tr.ExecShell,
},
{
ViewName: "containers",
Key: 'c',
@ -248,27 +235,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleContainersCustomCommand,
Description: gui.Tr.RunCustomCommand,
},
{
ViewName: "containers",
Key: 'b',
Modifier: gocui.ModNone,
Handler: gui.handleContainersBulkCommand,
Description: gui.Tr.ViewBulkCommands,
},
{
ViewName: "containers",
Key: 'w',
Modifier: gocui.ModNone,
Handler: gui.handleContainersOpenInBrowserCommand,
Description: gui.Tr.OpenInBrowser,
},
{
ViewName: "services",
Key: 'u',
Modifier: gocui.ModNone,
Handler: gui.handleServiceUp,
Description: gui.Tr.UpService,
},
{
ViewName: "services",
Key: 'd',
@ -283,13 +249,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleServiceStop,
Description: gui.Tr.Stop,
},
{
ViewName: "services",
Key: 'p',
Modifier: gocui.ModNone,
Handler: gui.handleServicePause,
Description: gui.Tr.Pause,
},
{
ViewName: "services",
Key: 'r',
@ -297,13 +256,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleServiceRestart,
Description: gui.Tr.Restart,
},
{
ViewName: "services",
Key: 'S',
Modifier: gocui.ModNone,
Handler: gui.handleServiceStart,
Description: gui.Tr.Start,
},
{
ViewName: "services",
Key: 'a',
@ -315,22 +267,22 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
ViewName: "services",
Key: 'm',
Modifier: gocui.ModNone,
Handler: gui.handleServiceRenderLogsToMain,
Handler: gui.handleServiceViewLogs,
Description: gui.Tr.ViewLogs,
},
{
ViewName: "services",
Key: 'U',
Key: '[',
Modifier: gocui.ModNone,
Handler: gui.handleProjectUp,
Description: gui.Tr.UpProject,
Handler: gui.handleServicesPrevContext,
Description: gui.Tr.PreviousContext,
},
{
ViewName: "services",
Key: 'D',
Key: ']',
Modifier: gocui.ModNone,
Handler: gui.handleProjectDown,
Description: gui.Tr.DownProject,
Handler: gui.handleServicesNextContext,
Description: gui.Tr.NextContext,
},
{
ViewName: "services",
@ -347,32 +299,18 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Description: gui.Tr.RunCustomCommand,
},
{
ViewName: "services",
Key: 'b',
ViewName: "images",
Key: '[',
Modifier: gocui.ModNone,
Handler: gui.handleServicesBulkCommand,
Description: gui.Tr.ViewBulkCommands,
},
{
ViewName: "services",
Key: 'E',
Modifier: gocui.ModNone,
Handler: gui.handleServicesExecShell,
Description: gui.Tr.ExecShell,
},
{
ViewName: "services",
Key: 'w',
Modifier: gocui.ModNone,
Handler: gui.handleServicesOpenInBrowserCommand,
Description: gui.Tr.OpenInBrowser,
Handler: gui.handleImagesPrevContext,
Description: gui.Tr.PreviousContext,
},
{
ViewName: "images",
Key: 'c',
Key: ']',
Modifier: gocui.ModNone,
Handler: gui.handleImagesCustomCommand,
Description: gui.Tr.RunCustomCommand,
Handler: gui.handleImagesNextContext,
Description: gui.Tr.NextContext,
},
{
ViewName: "images",
@ -383,17 +321,24 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
},
{
ViewName: "images",
Key: 'b',
Key: 'D',
Modifier: gocui.ModNone,
Handler: gui.handleImagesBulkCommand,
Description: gui.Tr.ViewBulkCommands,
Handler: gui.handlePruneImages,
Description: gui.Tr.PruneImages,
},
{
ViewName: "volumes",
Key: 'c',
Key: '[',
Modifier: gocui.ModNone,
Handler: gui.handleVolumesCustomCommand,
Description: gui.Tr.RunCustomCommand,
Handler: gui.handleVolumesPrevContext,
Description: gui.Tr.PreviousContext,
},
{
ViewName: "volumes",
Key: ']',
Modifier: gocui.ModNone,
Handler: gui.handleVolumesNextContext,
Description: gui.Tr.NextContext,
},
{
ViewName: "volumes",
@ -404,189 +349,48 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
},
{
ViewName: "volumes",
Key: 'b',
Key: 'D',
Modifier: gocui.ModNone,
Handler: gui.handleVolumesBulkCommand,
Description: gui.Tr.ViewBulkCommands,
},
{
ViewName: "networks",
Key: 'c',
Modifier: gocui.ModNone,
Handler: gui.handleNetworksCustomCommand,
Description: gui.Tr.RunCustomCommand,
},
{
ViewName: "networks",
Key: 'd',
Modifier: gocui.ModNone,
Handler: gui.handleNetworksRemoveMenu,
Description: gui.Tr.RemoveNetwork,
},
{
ViewName: "networks",
Key: 'b',
Modifier: gocui.ModNone,
Handler: gui.handleNetworksBulkCommand,
Description: gui.Tr.ViewBulkCommands,
},
{
ViewName: "main",
Key: gocui.KeyEsc,
Modifier: gocui.ModNone,
Handler: gui.handleExitMain,
Description: gui.Tr.Return,
},
{
ViewName: "main",
Key: gocui.KeyArrowLeft,
Modifier: gocui.ModNone,
Handler: gui.scrollLeftMain,
},
{
ViewName: "main",
Key: gocui.KeyArrowRight,
Modifier: gocui.ModNone,
Handler: gui.scrollRightMain,
},
{
ViewName: "main",
Key: 'h',
Modifier: gocui.ModNone,
Handler: gui.scrollLeftMain,
},
{
ViewName: "main",
Key: 'l',
Modifier: gocui.ModNone,
Handler: gui.scrollRightMain,
},
{
ViewName: "filter",
Key: gocui.KeyEnter,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.commitFilter),
},
{
ViewName: "filter",
Key: gocui.KeyEsc,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.escapeFilterPrompt),
},
{
ViewName: "",
Key: 'J',
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.scrollDownMain),
},
{
ViewName: "",
Key: 'K',
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.scrollUpMain),
},
{
ViewName: "",
Key: 'H',
Modifier: gocui.ModNone,
Handler: gui.scrollLeftMain,
},
{
ViewName: "",
Key: 'L',
Modifier: gocui.ModNone,
Handler: gui.scrollRightMain,
},
{
ViewName: "",
Key: '+',
Handler: wrappedHandler(gui.nextScreenMode),
Description: gui.Tr.LcNextScreenMode,
},
{
ViewName: "",
Key: '_',
Handler: wrappedHandler(gui.prevScreenMode),
Description: gui.Tr.LcPrevScreenMode,
Handler: gui.handlePruneVolumes,
Description: gui.Tr.PruneVolumes,
},
}
for _, panel := range gui.allSidePanels() {
// TODO: add more views here
for _, viewName := range []string{"status", "services", "containers", "images", "volumes", "menu"} {
bindings = append(bindings, []*Binding{
{ViewName: panel.GetView().Name(), Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: gui.previousView},
{ViewName: panel.GetView().Name(), Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, Handler: gui.nextView},
{ViewName: panel.GetView().Name(), Key: 'h', Modifier: gocui.ModNone, Handler: gui.previousView},
{ViewName: panel.GetView().Name(), Key: 'l', Modifier: gocui.ModNone, Handler: gui.nextView},
{ViewName: panel.GetView().Name(), Key: gocui.KeyTab, Modifier: gocui.ModNone, Handler: gui.nextView},
{ViewName: panel.GetView().Name(), Key: gocui.KeyBacktab, Modifier: gocui.ModNone, Handler: gui.previousView},
{ViewName: viewName, Key: gocui.KeyTab, Modifier: gocui.ModNone, Handler: gui.nextView},
{ViewName: viewName, Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: gui.previousView},
{ViewName: viewName, Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, Handler: gui.nextView},
{ViewName: viewName, Key: 'h', Modifier: gocui.ModNone, Handler: gui.previousView},
{ViewName: viewName, Key: 'l', Modifier: gocui.ModNone, Handler: gui.nextView},
}...)
}
setUpDownClickBindings := func(viewName string, onUp func() error, onDown func() error, onClick func() error) {
listPanelMap := map[string]struct {
prevLine func(*gocui.Gui, *gocui.View) error
nextLine func(*gocui.Gui, *gocui.View) error
focus func(*gocui.Gui, *gocui.View) error
}{
"menu": {prevLine: gui.handleMenuPrevLine, nextLine: gui.handleMenuNextLine, focus: gui.handleMenuSelect},
"services": {prevLine: gui.handleServicesPrevLine, nextLine: gui.handleServicesNextLine, focus: gui.handleServicesFocus},
"containers": {prevLine: gui.handleContainersPrevLine, nextLine: gui.handleContainersNextLine, focus: gui.handleContainersFocus},
"images": {prevLine: gui.handleImagesPrevLine, nextLine: gui.handleImagesNextLine, focus: gui.handleImagesFocus},
"volumes": {prevLine: gui.handleVolumesPrevLine, nextLine: gui.handleVolumesNextLine, focus: gui.handleVolumesFocus},
}
for viewName, functions := range listPanelMap {
bindings = append(bindings, []*Binding{
{ViewName: viewName, Key: 'k', Modifier: gocui.ModNone, Handler: wrappedHandler(onUp)},
{ViewName: viewName, Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, Handler: wrappedHandler(onUp)},
{ViewName: viewName, Key: gocui.MouseWheelUp, Modifier: gocui.ModNone, Handler: wrappedHandler(onUp)},
{ViewName: viewName, Key: 'j', Modifier: gocui.ModNone, Handler: wrappedHandler(onDown)},
{ViewName: viewName, Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, Handler: wrappedHandler(onDown)},
{ViewName: viewName, Key: gocui.MouseWheelDown, Modifier: gocui.ModNone, Handler: wrappedHandler(onDown)},
{ViewName: viewName, Key: gocui.MouseLeft, Modifier: gocui.ModNone, Handler: wrappedHandler(onClick)},
{ViewName: viewName, Key: 'k', Modifier: gocui.ModNone, Handler: functions.prevLine},
{ViewName: viewName, Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, Handler: functions.prevLine},
{ViewName: viewName, Key: gocui.MouseWheelUp, Modifier: gocui.ModNone, Handler: functions.prevLine},
{ViewName: viewName, Key: 'j', Modifier: gocui.ModNone, Handler: functions.nextLine},
{ViewName: viewName, Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, Handler: functions.nextLine},
{ViewName: viewName, Key: gocui.MouseWheelDown, Modifier: gocui.ModNone, Handler: functions.nextLine},
{ViewName: viewName, Key: gocui.MouseLeft, Modifier: gocui.ModNone, Handler: functions.focus},
}...)
}
bindings = append(bindings, []*Binding{
{Handler: gui.handleGoTo(gui.Panels.Projects.View), Key: '1', Description: gui.Tr.FocusProjects},
{Handler: gui.handleGoTo(gui.Panels.Services.View), Key: '2', Description: gui.Tr.FocusServices},
{Handler: gui.handleGoTo(gui.Panels.Containers.View), Key: '3', Description: gui.Tr.FocusContainers},
{Handler: gui.handleGoTo(gui.Panels.Images.View), Key: '4', Description: gui.Tr.FocusImages},
{Handler: gui.handleGoTo(gui.Panels.Volumes.View), Key: '5', Description: gui.Tr.FocusVolumes},
{Handler: gui.handleGoTo(gui.Panels.Networks.View), Key: '6', Description: gui.Tr.FocusNetworks},
}...)
for _, panel := range gui.allListPanels() {
setUpDownClickBindings(panel.GetView().Name(), panel.HandlePrevLine, panel.HandleNextLine, panel.HandleClick)
}
setUpDownClickBindings("main", gui.scrollUpMain, gui.scrollDownMain, gui.handleMainClick)
for _, panel := range gui.allSidePanels() {
bindings = append(bindings,
&Binding{
ViewName: panel.GetView().Name(),
Key: gocui.KeyEnter,
Modifier: gocui.ModNone,
Handler: gui.handleEnterMain,
Description: gui.Tr.FocusMain,
},
&Binding{
ViewName: panel.GetView().Name(),
Key: '[',
Modifier: gocui.ModNone,
Handler: wrappedHandler(panel.HandlePrevMainTab),
Description: gui.Tr.PreviousContext,
},
&Binding{
ViewName: panel.GetView().Name(),
Key: ']',
Modifier: gocui.ModNone,
Handler: wrappedHandler(panel.HandleNextMainTab),
Description: gui.Tr.NextContext,
},
)
}
for _, panel := range gui.allListPanels() {
if !panel.IsFilterDisabled() {
bindings = append(bindings, &Binding{
ViewName: panel.GetView().Name(),
Key: '/',
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.handleOpenFilter),
Description: gui.Tr.LcFilter,
})
}
}
return bindings
}
@ -598,16 +402,5 @@ func (gui *Gui) keybindings(g *gocui.Gui) error {
return err
}
}
if err := g.SetTabClickBinding("main", gui.onMainTabClick); err != nil {
return err
}
return nil
}
func wrappedHandler(f func() error) func(*gocui.Gui, *gocui.View) error {
return func(g *gocui.Gui, v *gocui.View) error {
return f()
}
}

View file

@ -2,10 +2,9 @@ package gui
import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/utils"
)
const UNKNOWN_VIEW_ERROR_MSG = "unknown view"
// getFocusLayout returns a manager function for when view gain and lose focus
func (gui *Gui) getFocusLayout() func(g *gocui.Gui) error {
var previousView *gocui.View
@ -16,8 +15,12 @@ func (gui *Gui) getFocusLayout() func(g *gocui.Gui) error {
}
// for now we don't consider losing focus to a popup panel as actually losing focus
if newView != previousView && !gui.isPopupPanel(newView.Name()) {
gui.onFocusLost(previousView, newView)
gui.onFocus(newView)
if err := gui.onFocusLost(previousView, newView); err != nil {
return err
}
if err := gui.onFocus(newView); err != nil {
return err
}
previousView = newView
}
return nil
@ -27,34 +30,36 @@ func (gui *Gui) getFocusLayout() func(g *gocui.Gui) error {
func (gui *Gui) onFocusChange() error {
currentView := gui.g.CurrentView()
for _, view := range gui.g.Views() {
view.Highlight = view == currentView && view.Name() != "main"
view.Highlight = view == currentView
}
return nil
}
func (gui *Gui) onFocusLost(v *gocui.View, newView *gocui.View) {
func (gui *Gui) onFocusLost(v *gocui.View, newView *gocui.View) error {
if v == nil {
return
}
if !gui.isPopupPanel(newView.Name()) {
v.ParentView = nil
return nil
}
// refocusing because in responsive mode (when the window is very short) we want to ensure that after the view size changes we can still see the last selected item
gui.focusPointInView(v)
gui.Log.Info(v.Name() + " focus lost")
}
func (gui *Gui) onFocus(v *gocui.View) {
if v == nil {
return
if err := gui.focusPointInView(v); err != nil {
return err
}
gui.focusPointInView(v)
gui.Log.Info(v.Name() + " focus lost")
return nil
}
func (gui *Gui) onFocus(v *gocui.View) error {
if v == nil {
return nil
}
if err := gui.focusPointInView(v); err != nil {
return err
}
gui.Log.Info(v.Name() + " focus gained")
return nil
}
// layout is called for every screen re-render e.g. when the screen is resized
@ -62,48 +67,206 @@ func (gui *Gui) layout(g *gocui.Gui) error {
g.Highlight = true
width, height := g.Size()
appStatus := gui.statusManager.getStatusString()
information := gui.Config.Version
viewDimensions := gui.getWindowDimensions(gui.getInformationContent(), appStatus)
// we assume that the view has already been created.
setViewFromDimensions := func(viewName string, windowName string) (*gocui.View, error) {
dimensionsObj, ok := viewDimensions[windowName]
view, err := g.View(viewName)
minimumHeight := 9
minimumWidth := 10
if height < minimumHeight || width < minimumWidth {
v, err := g.SetView("limit", 0, 0, width-1, height-1, 0)
if err != nil {
return nil, err
if err.Error() != "unknown view" {
return err
}
v.Title = gui.Tr.NotEnoughSpace
v.Wrap = true
_, _ = g.SetViewOnTop("limit")
}
if !ok {
// view not specified in dimensions object: so create the view and hide it
// making the view take up the whole space in the background in case it needs
// to render content as soon as it appears, because lazyloaded content (via a pty task)
// cares about the size of the view.
_, err := g.SetView(viewName, 0, 0, width, height, 0)
view.Visible = false
return view, err
}
frameOffset := 1
if view.Frame {
frameOffset = 0
}
_, err = g.SetView(
viewName,
dimensionsObj.X0-frameOffset,
dimensionsObj.Y0-frameOffset,
dimensionsObj.X1+frameOffset,
dimensionsObj.Y1+frameOffset,
0,
)
view.Visible = true
return view, err
return nil
}
for _, viewName := range gui.autoPositionedViewNames() {
_, err := setViewFromDimensions(viewName, viewName)
if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
currView := gui.g.CurrentView()
currentCyclebleView := gui.State.PreviousView
if currView != nil {
viewName := currView.Name()
usePreviouseView := true
for _, view := range gui.CyclableViews {
if view == viewName {
currentCyclebleView = viewName
usePreviouseView = false
break
}
}
if usePreviouseView {
currentCyclebleView = gui.State.PreviousView
}
}
usableSpace := height - 4
tallPanels := 3
var vHeights map[string]int
if gui.DockerCommand.InDockerComposeProject {
tallPanels++
vHeights = map[string]int{
"status": 3,
"services": usableSpace/tallPanels + usableSpace%tallPanels,
"containers": usableSpace / tallPanels,
"images": usableSpace / tallPanels,
"volumes": usableSpace / tallPanels,
"options": 1,
}
} else {
vHeights = map[string]int{
"status": 3,
"containers": usableSpace/tallPanels + usableSpace%tallPanels,
"images": usableSpace / tallPanels,
"volumes": usableSpace / tallPanels,
"options": 1,
}
}
if height < 28 {
defaultHeight := 3
if height < 21 {
defaultHeight = 1
}
vHeights = map[string]int{
"status": defaultHeight,
"containers": defaultHeight,
"images": defaultHeight,
"volumes": defaultHeight,
"options": defaultHeight,
}
if gui.DockerCommand.InDockerComposeProject {
vHeights["services"] = defaultHeight
}
vHeights[currentCyclebleView] = height - defaultHeight*tallPanels - 1
}
optionsVersionBoundary := width - max(len(utils.Decolorise(information)), 1)
leftSideWidth := width / 3
appStatus := gui.statusManager.getStatusString()
appStatusOptionsBoundary := 0
if appStatus != "" {
appStatusOptionsBoundary = len(appStatus) + 2
}
_, _ = g.SetViewOnBottom("limit")
g.DeleteView("limit")
v, err := g.SetView("main", leftSideWidth+1, 0, width-1, height-2, gocui.LEFT)
if err != nil {
if err.Error() != "unknown view" {
return err
}
v.Wrap = true
v.FgColor = gocui.ColorWhite
}
if v, err := g.SetView("status", 0, 0, leftSideWidth, vHeights["status"]-1, gocui.BOTTOM|gocui.RIGHT); err != nil {
if err.Error() != "unknown view" {
return err
}
v.Title = gui.Tr.StatusTitle
v.FgColor = gocui.ColorWhite
}
var servicesView *gocui.View
aboveContainersView := "status"
if gui.DockerCommand.InDockerComposeProject {
aboveContainersView = "services"
servicesView, err = g.SetViewBeneath("services", "status", vHeights["services"])
if err != nil {
if err.Error() != "unknown view" {
return err
}
servicesView.Highlight = true
servicesView.Title = gui.Tr.ServicesTitle
servicesView.FgColor = gocui.ColorWhite
}
}
containersView, err := g.SetViewBeneath("containers", aboveContainersView, vHeights["containers"])
if err != nil {
if err.Error() != "unknown view" {
return err
}
containersView.Highlight = true
if gui.Config.UserConfig.Gui.ShowAllContainers || !gui.DockerCommand.InDockerComposeProject {
containersView.Title = gui.Tr.ContainersTitle
} else {
containersView.Title = gui.Tr.StandaloneContainersTitle
}
containersView.FgColor = gocui.ColorWhite
}
imagesView, err := g.SetViewBeneath("images", "containers", vHeights["images"])
if err != nil {
if err.Error() != "unknown view" {
return err
}
imagesView.Highlight = true
imagesView.Title = gui.Tr.ImagesTitle
imagesView.FgColor = gocui.ColorWhite
}
volumesView, err := g.SetViewBeneath("volumes", "images", vHeights["volumes"])
if err != nil {
if err.Error() != "unknown view" {
return err
}
volumesView.Highlight = true
volumesView.Title = gui.Tr.VolumesTitle
volumesView.FgColor = gocui.ColorWhite
}
if v, err := g.SetView("options", appStatusOptionsBoundary-1, height-2, optionsVersionBoundary-1, height, 0); err != nil {
if err.Error() != "unknown view" {
return err
}
v.Frame = false
if v.FgColor, err = gui.GetOptionsPanelTextColor(); err != nil {
return err
}
}
if appStatusView, err := g.SetView("appStatus", -1, height-2, width, height, 0); err != nil {
if err.Error() != "unknown view" {
return err
}
appStatusView.BgColor = gocui.ColorDefault
appStatusView.FgColor = gocui.ColorCyan
appStatusView.Frame = false
if _, err := g.SetViewOnBottom("appStatus"); err != nil {
return err
}
}
if v, err := g.SetView("information", optionsVersionBoundary-1, height-2, width, height, 0); err != nil {
if err.Error() != "unknown view" {
return err
}
v.BgColor = gocui.ColorDefault
v.FgColor = gocui.ColorGreen
v.Frame = false
if err := gui.renderString(g, "information", information); err != nil {
return err
}
// doing this here because it'll only happen once
if err := gui.loadNewDirectory(); err != nil {
return err
}
}
if gui.g.CurrentView() == nil {
v, err := gui.g.View(gui.State.PreviousView)
if err != nil {
v = gui.getServicesView()
}
if err := gui.switchFocus(gui.g, nil, v); err != nil {
return err
}
}
@ -115,21 +278,29 @@ func (gui *Gui) layout(g *gocui.Gui) error {
return gui.resizeCurrentPopupPanel(g)
}
func (gui *Gui) focusPointInView(view *gocui.View) {
type listViewState struct {
selectedLine int
lineCount int
}
func (gui *Gui) focusPointInView(view *gocui.View) error {
if view == nil {
return
return nil
}
for _, panel := range gui.allListPanels() {
if panel.GetView() == view {
panel.Refocus()
return
listViews := map[string]listViewState{
"containers": {selectedLine: gui.State.Panels.Containers.SelectedLine, lineCount: len(gui.DockerCommand.DisplayContainers)},
"images": {selectedLine: gui.State.Panels.Images.SelectedLine, lineCount: len(gui.DockerCommand.Images)},
"volumes": {selectedLine: gui.State.Panels.Volumes.SelectedLine, lineCount: len(gui.DockerCommand.Volumes)},
"services": {selectedLine: gui.State.Panels.Services.SelectedLine, lineCount: len(gui.DockerCommand.Services)},
"menu": {selectedLine: gui.State.Panels.Menu.SelectedLine, lineCount: gui.State.MenuItemCount},
}
if state, ok := listViews[view.Name()]; ok {
if err := gui.focusPoint(0, state.selectedLine, state.lineCount, view); err != nil {
return err
}
}
}
func (gui *Gui) prepareView(viewName string) (*gocui.View, error) {
// arbitrarily giving the view enough size so that we don't get an error, but
// it's expected that the view will be given the correct size before being shown
return gui.g.SetView(viewName, 0, 0, 10, 10, 0)
return nil
}

View file

@ -6,108 +6,28 @@ import (
"github.com/jesseduffield/gocui"
)
func (gui *Gui) scrollUpMain() error {
mainView := gui.Views.Main
func (gui *Gui) scrollUpMain(g *gocui.Gui, v *gocui.View) error {
mainView, _ := g.View("main")
mainView.Autoscroll = false
ox, oy := mainView.Origin()
newOy := int(math.Max(0, float64(oy-gui.Config.UserConfig.Gui.ScrollHeight)))
return mainView.SetOrigin(ox, newOy)
}
func (gui *Gui) scrollDownMain() error {
mainView := gui.Views.Main
mainView.Autoscroll = false
func (gui *Gui) scrollDownMain(g *gocui.Gui, v *gocui.View) error {
mainView, _ := g.View("main")
ox, oy := mainView.Origin()
reservedLines := 0
y := oy
if !gui.Config.UserConfig.Gui.ScrollPastBottom {
_, sizeY := mainView.Size()
reservedLines = sizeY
_, sy := mainView.Size()
y += sy
}
totalLines := mainView.ViewLinesHeight()
if oy+reservedLines >= totalLines {
return nil
}
// for some reason we can't work out whether we've hit the bottomq
// there is a large discrepancy in the origin's y value and the length of BufferLines
return mainView.SetOrigin(ox, oy+gui.Config.UserConfig.Gui.ScrollHeight)
}
func (gui *Gui) scrollLeftMain(g *gocui.Gui, v *gocui.View) error {
mainView := gui.Views.Main
ox, oy := mainView.Origin()
newOx := int(math.Max(0, float64(ox-gui.Config.UserConfig.Gui.ScrollHeight)))
return mainView.SetOrigin(newOx, oy)
}
func (gui *Gui) scrollRightMain(g *gocui.Gui, v *gocui.View) error {
mainView := gui.Views.Main
ox, oy := mainView.Origin()
content := mainView.ViewBufferLines()
var largestNumberOfCharacters int
for _, txt := range content {
if len(txt) > largestNumberOfCharacters {
largestNumberOfCharacters = len(txt)
}
}
sizeX, _ := mainView.Size()
if ox+sizeX >= largestNumberOfCharacters {
return nil
}
return mainView.SetOrigin(ox+gui.Config.UserConfig.Gui.ScrollHeight, oy)
}
func (gui *Gui) autoScrollMain(g *gocui.Gui, v *gocui.View) error {
gui.Views.Main.Autoscroll = true
gui.getMainView().Autoscroll = true
return nil
}
func (gui *Gui) jumpToTopMain(g *gocui.Gui, v *gocui.View) error {
gui.Views.Main.Autoscroll = false
_ = gui.Views.Main.SetOrigin(0, 0)
_ = gui.Views.Main.SetCursor(0, 0)
return nil
}
func (gui *Gui) onMainTabClick(tabIndex int) error {
gui.Log.Warn(tabIndex)
currentSidePanel, ok := gui.currentSidePanel()
if !ok {
return nil
}
currentSidePanel.SetMainTabIndex(tabIndex)
return currentSidePanel.HandleSelect()
}
func (gui *Gui) handleEnterMain(g *gocui.Gui, v *gocui.View) error {
mainView := gui.Views.Main
mainView.ParentView = v
return gui.switchFocus(mainView)
}
func (gui *Gui) handleExitMain(g *gocui.Gui, v *gocui.View) error {
v.ParentView = nil
return gui.returnFocus()
}
func (gui *Gui) handleMainClick() error {
if gui.popupPanelFocused() {
return nil
}
currentView := gui.g.CurrentView()
if currentView.Name() != "main" {
gui.Views.Main.ParentView = currentView
}
return gui.switchFocus(gui.Views.Main)
}

View file

@ -1,133 +1,102 @@
package gui
import (
"github.com/jesseduffield/lazydocker/pkg/gui/panels"
"github.com/jesseduffield/lazydocker/pkg/gui/presentation"
"github.com/jesseduffield/lazydocker/pkg/gui/types"
"fmt"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/utils"
)
type CreateMenuOptions struct {
Title string
Items []*types.MenuItem
HideCancel bool
// list panel functions
func (gui *Gui) handleMenuSelect(g *gocui.Gui, v *gocui.View) error {
return gui.focusPoint(0, gui.State.Panels.Menu.SelectedLine, gui.State.MenuItemCount, v)
}
func (gui *Gui) getMenuPanel() *panels.SideListPanel[*types.MenuItem] {
return &panels.SideListPanel[*types.MenuItem]{
ListPanel: panels.ListPanel[*types.MenuItem]{
List: panels.NewFilteredList[*types.MenuItem](),
View: gui.Views.Menu,
},
NoItemsMessage: "",
Gui: gui.intoInterface(),
OnClick: gui.onMenuPress,
Sort: nil,
GetTableCells: presentation.GetMenuItemDisplayStrings,
OnRerender: func() error {
return gui.resizePopupPanel(gui.Views.Menu)
},
// so that we can avoid some UI trickiness, the menu will not have filtering
// abillity yet. To support it, we would need to have filter state against
// each panel (e.g. for when you filter the images panel, then bring up
// the options menu, then try to filter that too.
DisableFilter: true,
}
func (gui *Gui) handleMenuNextLine(g *gocui.Gui, v *gocui.View) error {
panelState := gui.State.Panels.Menu
gui.changeSelectedLine(&panelState.SelectedLine, v.LinesHeight(), false)
return gui.handleMenuSelect(g, v)
}
func (gui *Gui) onMenuPress(menuItem *types.MenuItem) error {
if err := gui.handleMenuClose(); err != nil {
return err
}
func (gui *Gui) handleMenuPrevLine(g *gocui.Gui, v *gocui.View) error {
panelState := gui.State.Panels.Menu
gui.changeSelectedLine(&panelState.SelectedLine, v.LinesHeight(), true)
if menuItem.OnPress != nil {
return menuItem.OnPress()
}
return nil
}
func (gui *Gui) handleMenuPress() error {
selectedMenuItem, err := gui.Panels.Menu.GetSelectedItem()
if err != nil {
return nil
}
return gui.onMenuPress(selectedMenuItem)
}
func (gui *Gui) Menu(opts CreateMenuOptions) error {
if !opts.HideCancel {
// this is mutative but I'm okay with that for now
opts.Items = append(opts.Items, &types.MenuItem{
LabelColumns: []string{gui.Tr.Cancel},
OnPress: func() error {
return nil
},
})
}
maxColumnSize := 1
for _, item := range opts.Items {
if item.LabelColumns == nil {
item.LabelColumns = []string{item.Label}
}
if item.OpensMenu {
item.LabelColumns[0] = utils.OpensMenuStyle(item.LabelColumns[0])
}
maxColumnSize = utils.Max(maxColumnSize, len(item.LabelColumns))
}
for _, item := range opts.Items {
if len(item.LabelColumns) < maxColumnSize {
// we require that each item has the same number of columns so we're padding out with blank strings
// if this item has too few
item.LabelColumns = append(item.LabelColumns, make([]string, maxColumnSize-len(item.LabelColumns))...)
}
}
gui.Panels.Menu.SetItems(opts.Items)
gui.Panels.Menu.SetSelectedLineIdx(0)
if err := gui.Panels.Menu.RerenderList(); err != nil {
return err
}
gui.Views.Menu.Title = opts.Title
gui.Views.Menu.Visible = true
return gui.switchFocus(gui.Views.Menu)
return gui.handleMenuSelect(g, v)
}
// specific functions
func (gui *Gui) renderMenuOptions() error {
optionsMap := map[string]string{
"esc": gui.Tr.Close,
"esc/q": gui.Tr.Close,
"↑ ↓": gui.Tr.Navigate,
"enter": gui.Tr.Execute,
"space": gui.Tr.Execute,
}
return gui.renderOptionsMap(optionsMap)
}
func (gui *Gui) handleMenuClose() error {
gui.Views.Menu.Visible = false
// this code is here for when we do add filter ability to the menu panel,
// though it's currently disabled
if gui.State.Filter.panel == gui.Panels.Menu {
if err := gui.clearFilter(); err != nil {
func (gui *Gui) handleMenuClose(g *gocui.Gui, v *gocui.View) error {
for _, key := range []gocui.Key{gocui.KeySpace, gocui.KeyEnter} {
if err := g.DeleteKeybinding("menu", key, gocui.ModNone); err != nil {
return err
}
}
err := g.DeleteView("menu")
if err != nil {
return err
}
return gui.returnFocus(g, v)
}
// we need to remove the view from the view stack because we're about to
// return focus and don't want to land in the search view when it was searching
// the menu in the first place
gui.removeViewFromStack(gui.Views.Filter)
func (gui *Gui) createMenu(title string, items interface{}, itemCount int, handlePress func(int) error) error {
isFocused := gui.g.CurrentView().Name() == "menu"
gui.State.MenuItemCount = itemCount
list, err := utils.RenderList(items, utils.IsFocused(isFocused))
if err != nil {
return err
}
return gui.returnFocus()
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(gui.g, false, list)
menuView, _ := gui.g.SetView("menu", x0, y0, x1, y1, 0)
menuView.Title = title
menuView.FgColor = gocui.ColorWhite
menuView.Clear()
fmt.Fprint(menuView, list)
gui.State.Panels.Menu.SelectedLine = 0
wrappedHandlePress := func(g *gocui.Gui, v *gocui.View) error {
selectedLine := gui.State.Panels.Menu.SelectedLine
if err := handlePress(selectedLine); err != nil {
return err
}
if _, err := gui.g.View("menu"); err == nil {
if _, err := gui.g.SetViewOnBottom("menu"); err != nil {
return err
}
}
return gui.returnFocus(gui.g, menuView)
}
for _, key := range []gocui.Key{gocui.KeySpace, gocui.KeyEnter} {
_ = gui.g.DeleteKeybinding("menu", key, gocui.ModNone)
if err := gui.g.SetKeybinding("menu", key, gocui.ModNone, wrappedHandlePress); err != nil {
return err
}
}
gui.g.Update(func(g *gocui.Gui) error {
if _, err := gui.g.View("menu"); err == nil {
if _, err := g.SetViewOnTop("menu"); err != nil {
return err
}
}
currentView := gui.g.CurrentView()
return gui.switchFocus(gui.g, currentView, menuView)
})
return nil
}

View file

@ -1,179 +0,0 @@
package gui
import (
"strconv"
"github.com/fatih/color"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/gui/panels"
"github.com/jesseduffield/lazydocker/pkg/gui/presentation"
"github.com/jesseduffield/lazydocker/pkg/gui/types"
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
)
func (gui *Gui) getNetworksPanel() *panels.SideListPanel[*commands.Network] {
return &panels.SideListPanel[*commands.Network]{
ContextState: &panels.ContextState[*commands.Network]{
GetMainTabs: func() []panels.MainTab[*commands.Network] {
return []panels.MainTab[*commands.Network]{
{
Key: "config",
Title: gui.Tr.ConfigTitle,
Render: gui.renderNetworkConfig,
},
}
},
GetItemContextCacheKey: func(network *commands.Network) string {
return "networks-" + network.Name
},
},
ListPanel: panels.ListPanel[*commands.Network]{
List: panels.NewFilteredList[*commands.Network](),
View: gui.Views.Networks,
},
NoItemsMessage: gui.Tr.NoNetworks,
Gui: gui.intoInterface(),
// we're sorting these networks based on whether they have labels defined,
// because those are the ones you typically care about.
// Within that, we also sort them alphabetically
Sort: func(a *commands.Network, b *commands.Network) bool {
return a.Name < b.Name
},
GetTableCells: presentation.GetNetworkDisplayStrings,
}
}
func (gui *Gui) renderNetworkConfig(network *commands.Network) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string { return gui.networkConfigStr(network) })
}
func (gui *Gui) networkConfigStr(network *commands.Network) string {
padding := 15
output := ""
output += utils.WithPadding("ID: ", padding) + network.Network.ID + "\n"
output += utils.WithPadding("Name: ", padding) + network.Name + "\n"
output += utils.WithPadding("Driver: ", padding) + network.Network.Driver + "\n"
output += utils.WithPadding("Scope: ", padding) + network.Network.Scope + "\n"
output += utils.WithPadding("EnabledIPV6: ", padding) + strconv.FormatBool(network.Network.EnableIPv6) + "\n"
output += utils.WithPadding("Internal: ", padding) + strconv.FormatBool(network.Network.Internal) + "\n"
output += utils.WithPadding("Attachable: ", padding) + strconv.FormatBool(network.Network.Attachable) + "\n"
output += utils.WithPadding("Ingress: ", padding) + strconv.FormatBool(network.Network.Ingress) + "\n"
output += utils.WithPadding("Containers: ", padding)
if len(network.Network.Containers) > 0 {
output += "\n"
for _, v := range network.Network.Containers {
output += utils.FormatMapItem(padding, v.Name, v.EndpointID)
}
} else {
output += "none\n"
}
output += "\n"
output += utils.WithPadding("Labels: ", padding) + utils.FormatMap(padding, network.Network.Labels) + "\n"
output += utils.WithPadding("Options: ", padding) + utils.FormatMap(padding, network.Network.Options)
return output
}
func (gui *Gui) reloadNetworks() error {
if err := gui.refreshStateNetworks(); err != nil {
return err
}
return gui.Panels.Networks.RerenderList()
}
func (gui *Gui) refreshStateNetworks() error {
networks, err := gui.DockerCommand.RefreshNetworks()
if err != nil {
return err
}
gui.Panels.Networks.SetItems(networks)
return nil
}
func (gui *Gui) handleNetworksRemoveMenu(g *gocui.Gui, v *gocui.View) error {
network, err := gui.Panels.Networks.GetSelectedItem()
if err != nil {
return nil
}
type removeNetworkOption struct {
description string
command string
}
options := []*removeNetworkOption{
{
description: gui.Tr.Remove,
command: utils.WithShortSha("docker network rm " + network.Name),
},
}
menuItems := lo.Map(options, func(option *removeNetworkOption, _ int) *types.MenuItem {
return &types.MenuItem{
LabelColumns: []string{option.description, color.New(color.FgRed).Sprint(option.command)},
OnPress: func() error {
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
if err := network.Remove(); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
},
}
})
return gui.Menu(CreateMenuOptions{
Title: "",
Items: menuItems,
})
}
func (gui *Gui) handlePruneNetworks() error {
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmPruneNetworks, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.PruningStatus, func() error {
err := gui.DockerCommand.PruneNetworks()
if err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
}, nil)
}
func (gui *Gui) handleNetworksCustomCommand(g *gocui.Gui, v *gocui.View) error {
network, err := gui.Panels.Networks.GetSelectedItem()
if err != nil {
return nil
}
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{
Network: network,
})
customCommands := gui.Config.UserConfig.CustomCommands.Networks
return gui.createCustomCommandMenu(customCommands, commandObject)
}
func (gui *Gui) handleNetworksBulkCommand(g *gocui.Gui, v *gocui.View) error {
baseBulkCommands := []config.CustomCommand{
{
Name: gui.Tr.PruneNetworks,
InternalFunction: gui.handlePruneNetworks,
},
}
bulkCommands := append(baseBulkCommands, gui.Config.UserConfig.BulkCommands.Networks...)
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{})
return gui.createBulkCommandMenu(bulkCommands, commandObject)
}

View file

@ -1,14 +1,17 @@
package gui
import (
"github.com/samber/lo"
"strings"
"github.com/go-errors/errors"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/gui/types"
)
func (gui *Gui) getBindings(v *gocui.View) []*Binding {
var bindingsGlobal, bindingsPanel []*Binding
var (
bindingsGlobal, bindingsPanel []*Binding
)
bindings := gui.GetInitialKeybindings()
@ -23,24 +26,6 @@ func (gui *Gui) getBindings(v *gocui.View) []*Binding {
}
}
// check if we have any keybindings from our parent view to add
if v.ParentView != nil {
L:
for _, binding := range bindings {
if binding.GetKey() != "" && binding.Description != "" {
if binding.ViewName == v.ParentView.Name() {
// if we haven't got a conflict we will display the binding
for _, ownBinding := range bindingsPanel {
if ownBinding.GetKey() == binding.GetKey() {
continue L
}
}
bindingsPanel = append(bindingsPanel, binding)
}
}
}
}
// append dummy element to have a separator between
// panel and global keybindings
bindingsPanel = append(bindingsPanel, &Binding{})
@ -48,26 +33,21 @@ func (gui *Gui) getBindings(v *gocui.View) []*Binding {
}
func (gui *Gui) handleCreateOptionsMenu(g *gocui.Gui, v *gocui.View) error {
if gui.isPopupPanel(v.Name()) {
return nil
bindings := gui.getBindings(v)
handleMenuPress := func(index int) error {
if bindings[index].Key == nil {
return nil
}
if index >= len(bindings) {
return errors.New("Index is greater than size of bindings")
}
err := gui.handleMenuClose(g, v)
if err != nil {
return err
}
return bindings[index].Handler(g, v)
}
menuItems := lo.Map(gui.getBindings(v), func(binding *Binding, _ int) *types.MenuItem {
return &types.MenuItem{
LabelColumns: []string{binding.GetKey(), binding.Description},
OnPress: func() error {
if binding.Key == nil {
return nil
}
return binding.Handler(g, v)
},
}
})
return gui.Menu(CreateMenuOptions{
Title: gui.Tr.MenuTitle,
Items: menuItems,
HideCancel: true,
})
return gui.createMenu(strings.Title(gui.Tr.Menu), bindings, len(bindings), handleMenuPress)
}

View file

@ -1,7 +0,0 @@
package gui
import "github.com/jesseduffield/lazydocker/pkg/gui/panels"
func (gui *Gui) intoInterface() panels.IGui {
return gui
}

View file

@ -1,69 +0,0 @@
package panels
import (
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/samber/lo"
)
// A 'context' generally corresponds to an item and the tab in the main panel that we're
// displaying. So if we switch to a new item, or change the tab in the panel panel
// for the current item, we end up with a new context. When we have a new context,
// we render new content to the main panel.
type ContextState[T any] struct {
// index of the currently selected tab in the main view.
mainTabIdx int
// this function returns the tabs that we can display for an item (the tabs
// are shown on the main view)
GetMainTabs func() []MainTab[T]
// This tells us whether we need to re-render to the main panel for a given item.
// This should include the item's ID and if you want to invalidate the cache for
// some other reason, you can add that to the key as well (e.g. the container's state).
GetItemContextCacheKey func(item T) string
}
type MainTab[T any] struct {
// key used as part of the context cache key
Key string
// title of the tab, rendered in the main view
Title string
// function to render the content of the tab
Render func(item T) tasks.TaskFunc
}
func (self *ContextState[T]) GetMainTabTitles() []string {
return lo.Map(self.GetMainTabs(), func(tab MainTab[T], _ int) string {
return tab.Title
})
}
func (self *ContextState[T]) GetCurrentContextKey(item T) string {
return self.GetItemContextCacheKey(item) + "-" + self.GetCurrentMainTab().Key
}
func (self *ContextState[T]) GetCurrentMainTab() MainTab[T] {
return self.GetMainTabs()[self.mainTabIdx]
}
func (self *ContextState[T]) HandleNextMainTab() {
tabs := self.GetMainTabs()
if len(tabs) == 0 {
return
}
self.mainTabIdx = (self.mainTabIdx + 1) % len(tabs)
}
func (self *ContextState[T]) HandlePrevMainTab() {
tabs := self.GetMainTabs()
if len(tabs) == 0 {
return
}
self.mainTabIdx = (self.mainTabIdx - 1 + len(tabs)) % len(tabs)
}
func (self *ContextState[T]) SetMainTabIndex(index int) {
self.mainTabIdx = index
}

View file

@ -1,111 +0,0 @@
package panels
import (
"sort"
"sync"
)
type FilteredList[T comparable] struct {
allItems []T
// indices of items in the allItems slice that are included in the filtered list
indices []int
mutex sync.RWMutex
}
func NewFilteredList[T comparable]() *FilteredList[T] {
return &FilteredList[T]{}
}
func (self *FilteredList[T]) SetItems(items []T) {
self.mutex.Lock()
defer self.mutex.Unlock()
self.allItems = items
self.indices = make([]int, len(items))
for i := range self.indices {
self.indices[i] = i
}
}
func (self *FilteredList[T]) Filter(filter func(T, int) bool) {
self.mutex.Lock()
defer self.mutex.Unlock()
self.indices = self.indices[:0]
for i, item := range self.allItems {
if filter(item, i) {
self.indices = append(self.indices, i)
}
}
}
func (self *FilteredList[T]) Sort(less func(T, T) bool) {
self.mutex.Lock()
defer self.mutex.Unlock()
if less == nil {
return
}
sort.Slice(self.indices, func(i, j int) bool {
return less(self.allItems[self.indices[i]], self.allItems[self.indices[j]])
})
}
func (self *FilteredList[T]) Get(index int) T {
self.mutex.RLock()
defer self.mutex.RUnlock()
return self.allItems[self.indices[index]]
}
func (self *FilteredList[T]) TryGet(index int) (T, bool) {
self.mutex.RLock()
defer self.mutex.RUnlock()
if index < 0 || index >= len(self.indices) {
var zero T
return zero, false
}
return self.allItems[self.indices[index]], true
}
// returns the length of the filtered list
func (self *FilteredList[T]) Len() int {
self.mutex.RLock()
defer self.mutex.RUnlock()
return len(self.indices)
}
func (self *FilteredList[T]) GetIndex(item T) int {
self.mutex.RLock()
defer self.mutex.RUnlock()
for i, index := range self.indices {
if self.allItems[index] == item {
return i
}
}
return -1
}
func (self *FilteredList[T]) GetItems() []T {
self.mutex.RLock()
defer self.mutex.RUnlock()
result := make([]T, len(self.indices))
for i, index := range self.indices {
result[i] = self.allItems[index]
}
return result
}
func (self *FilteredList[T]) GetAllItems() []T {
self.mutex.RLock()
defer self.mutex.RUnlock()
return self.allItems
}

View file

@ -1,183 +0,0 @@
package panels
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFilteredListGet(t *testing.T) {
tests := []struct {
f *FilteredList[int]
args int
want int
}{
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: 1,
want: 2,
},
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: 2,
want: 3,
},
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
args: 0,
want: 2,
},
}
for _, tt := range tests {
if got := tt.f.Get(tt.args); got != tt.want {
t.Errorf("FilteredList.Get() = %v, want %v", got, tt.want)
}
}
}
func TestFilteredListLen(t *testing.T) {
tests := []struct {
f *FilteredList[int]
want int
}{
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
want: 3,
},
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
want: 1,
},
}
for _, tt := range tests {
if got := tt.f.Len(); got != tt.want {
t.Errorf("FilteredList.Len() = %v, want %v", got, tt.want)
}
}
}
func TestFilteredListFilter(t *testing.T) {
tests := []struct {
f *FilteredList[int]
args func(int, int) bool
want *FilteredList[int]
}{
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: func(i int, _ int) bool { return i%2 == 0 },
want: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
},
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: func(i int, _ int) bool { return i%2 == 1 },
want: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 2}},
},
}
for _, tt := range tests {
tt.f.Filter(tt.args)
assert.EqualValues(t, tt.f.indices, tt.want.indices)
}
}
func TestFilteredListSort(t *testing.T) {
tests := []struct {
f *FilteredList[int]
args func(int, int) bool
want *FilteredList[int]
}{
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: func(i int, j int) bool { return i < j },
want: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
},
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: func(i int, j int) bool { return i > j },
want: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{2, 1, 0}},
},
}
for _, tt := range tests {
tt.f.Sort(tt.args)
assert.EqualValues(t, tt.f.indices, tt.want.indices)
}
}
func TestFilteredListGetIndex(t *testing.T) {
tests := []struct {
f *FilteredList[int]
args int
want int
}{
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: 1,
want: 0,
},
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: 2,
want: 1,
},
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
args: 0,
want: -1,
},
}
for _, tt := range tests {
if got := tt.f.GetIndex(tt.args); got != tt.want {
t.Errorf("FilteredList.GetIndex() = %v, want %v", got, tt.want)
}
}
}
func TestFilteredListGetItems(t *testing.T) {
tests := []struct {
f *FilteredList[int]
want []int
}{
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
want: []int{1, 2, 3},
},
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
want: []int{2},
},
}
for _, tt := range tests {
got := tt.f.GetItems()
assert.EqualValues(t, got, tt.want)
}
}
func TestFilteredListSetItems(t *testing.T) {
tests := []struct {
f *FilteredList[int]
args []int
want *FilteredList[int]
}{
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{0, 1, 2}},
args: []int{4, 5, 6},
want: &FilteredList[int]{allItems: []int{4, 5, 6}, indices: []int{0, 1, 2}},
},
{
f: &FilteredList[int]{allItems: []int{1, 2, 3}, indices: []int{1}},
args: []int{4},
want: &FilteredList[int]{allItems: []int{4}, indices: []int{0}},
},
}
for _, tt := range tests {
tt.f.SetItems(tt.args)
assert.EqualValues(t, tt.f.indices, tt.want.indices)
assert.EqualValues(t, tt.f.allItems, tt.want.allItems)
}
}

View file

@ -1,42 +0,0 @@
package panels
import (
"github.com/jesseduffield/gocui"
lcUtils "github.com/jesseduffield/lazycore/pkg/utils"
)
type ListPanel[T comparable] struct {
SelectedIdx int
List *FilteredList[T]
View *gocui.View
}
func (self *ListPanel[T]) SetSelectedLineIdx(value int) {
clampedValue := 0
if self.List.Len() > 0 {
clampedValue = lcUtils.Clamp(value, 0, self.List.Len()-1)
}
self.SelectedIdx = clampedValue
}
func (self *ListPanel[T]) clampSelectedLineIdx() {
clamped := lcUtils.Clamp(self.SelectedIdx, 0, self.List.Len()-1)
if clamped != self.SelectedIdx {
self.SelectedIdx = clamped
}
}
// moves the cursor up or down by the given amount (up for negative values)
func (self *ListPanel[T]) moveSelectedLine(delta int) {
self.SetSelectedLineIdx(self.SelectedIdx + delta)
}
func (self *ListPanel[T]) SelectNextLine() {
self.moveSelectedLine(1)
}
func (self *ListPanel[T]) SelectPrevLine() {
self.moveSelectedLine(-1)
}

View file

@ -1,280 +0,0 @@
package panels
import (
"context"
"fmt"
"strings"
"github.com/go-errors/errors"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
)
type ISideListPanel interface {
SetMainTabIndex(int)
HandleSelect() error
GetView() *gocui.View
Refocus()
RerenderList() error
IsFilterDisabled() bool
IsHidden() bool
HandleNextLine() error
HandlePrevLine() error
HandleClick() error
HandlePrevMainTab() error
HandleNextMainTab() error
}
// list panel at the side of the screen that renders content to the main panel
type SideListPanel[T comparable] struct {
ContextState *ContextState[T]
ListPanel[T]
// message to render in the main view if there are no items in the panel
// and it has focus. Leave empty if you don't want to render anything
NoItemsMessage string
// a representation of the gui
Gui IGui
// this Filter is applied on top of additional default filters
Filter func(T) bool
Sort func(a, b T) bool
// 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
// function to be called after re-rendering list. Can be nil
OnRerender func() error
// set this to true if you don't want to allow manual filtering via '/'
DisableFilter bool
// This can be nil if you want to always show the panel
Hide func() bool
}
var _ ISideListPanel = &SideListPanel[int]{}
type IGui interface {
HandleClick(v *gocui.View, itemCount int, selectedLine *int, handleSelect func() error) error
NewSimpleRenderStringTask(getContent func() string) tasks.TaskFunc
FocusY(selectedLine int, itemCount int, view *gocui.View)
ShouldRefresh(contextKey string) bool
GetMainView() *gocui.View
IsCurrentView(*gocui.View) bool
FilterString(view *gocui.View) string
IgnoreStrings() []string
Update(func() error)
QueueTask(f func(ctx context.Context)) error
}
func (self *SideListPanel[T]) HandleClick() error {
itemCount := self.List.Len()
handleSelect := self.HandleSelect
selectedLine := &self.SelectedIdx
if err := self.Gui.HandleClick(self.View, itemCount, selectedLine, handleSelect); err != nil {
return err
}
if self.OnClick != nil {
selectedItem, err := self.GetSelectedItem()
if err == nil {
return self.OnClick(selectedItem)
}
}
return nil
}
func (self *SideListPanel[T]) GetView() *gocui.View {
return self.View
}
func (self *SideListPanel[T]) HandleSelect() error {
item, err := self.GetSelectedItem()
if err != nil {
if err.Error() != self.NoItemsMessage {
return err
}
if self.NoItemsMessage != "" {
self.Gui.NewSimpleRenderStringTask(func() string { return self.NoItemsMessage })
}
return nil
}
self.Refocus()
if self.OnSelect != nil {
if err := self.OnSelect(item); err != nil {
return err
}
}
return self.renderContext(item)
}
func (self *SideListPanel[T]) renderContext(item T) error {
if self.ContextState == nil {
return nil
}
key := self.ContextState.GetCurrentContextKey(item)
if !self.Gui.ShouldRefresh(key) {
return nil
}
mainView := self.Gui.GetMainView()
mainView.Tabs = self.ContextState.GetMainTabTitles()
mainView.TabIndex = self.ContextState.mainTabIdx
task := self.ContextState.GetCurrentMainTab().Render(item)
return self.Gui.QueueTask(task)
}
func (self *SideListPanel[T]) GetSelectedItem() (T, error) {
var zero T
item, ok := self.List.TryGet(self.SelectedIdx)
if !ok {
// could probably have a better error here
return zero, errors.New(self.NoItemsMessage)
}
return item, nil
}
func (self *SideListPanel[T]) HandleNextLine() error {
self.SelectNextLine()
return self.HandleSelect()
}
func (self *SideListPanel[T]) HandlePrevLine() error {
self.SelectPrevLine()
return self.HandleSelect()
}
func (self *SideListPanel[T]) HandleNextMainTab() error {
if self.ContextState == nil {
return nil
}
self.ContextState.HandleNextMainTab()
return self.HandleSelect()
}
func (self *SideListPanel[T]) HandlePrevMainTab() error {
if self.ContextState == nil {
return nil
}
self.ContextState.HandlePrevMainTab()
return self.HandleSelect()
}
func (self *SideListPanel[T]) Refocus() {
self.Gui.FocusY(self.SelectedIdx, self.List.Len(), self.View)
}
func (self *SideListPanel[T]) SetItems(items []T) {
self.List.SetItems(items)
self.FilterAndSort()
}
func (self *SideListPanel[T]) FilterAndSort() {
filterString := self.Gui.FilterString(self.View)
self.List.Filter(func(item T, index int) bool {
if self.Filter != nil && !self.Filter(item) {
return false
}
if lo.SomeBy(self.Gui.IgnoreStrings(), func(ignore string) bool {
return lo.SomeBy(self.GetTableCells(item), func(searchString string) bool {
return strings.Contains(searchString, ignore)
})
}) {
return false
}
if filterString != "" {
return lo.SomeBy(self.GetTableCells(item), func(searchString string) bool {
return strings.Contains(searchString, filterString)
})
}
return true
})
self.List.Sort(self.Sort)
self.clampSelectedLineIdx()
}
func (self *SideListPanel[T]) RerenderList() error {
self.FilterAndSort()
self.Gui.Update(func() error {
self.View.Clear()
table := lo.Map(self.List.GetItems(), func(item T, index int) []string {
return self.GetTableCells(item)
})
renderedTable, err := utils.RenderTable(table)
if err != nil {
return err
}
fmt.Fprint(self.View, renderedTable)
if self.OnRerender != nil {
if err := self.OnRerender(); err != nil {
return err
}
}
if self.Gui.IsCurrentView(self.View) {
return self.HandleSelect()
}
return nil
})
return nil
}
func (self *SideListPanel[T]) SetMainTabIndex(index int) {
if self.ContextState == nil {
return
}
self.ContextState.SetMainTabIndex(index)
}
func (self *SideListPanel[T]) IsFilterDisabled() bool {
return self.DisableFilter
}
func (self *SideListPanel[T]) IsHidden() bool {
if self.Hide == nil {
return false
}
return self.Hide()
}

View file

@ -1,146 +0,0 @@
package presentation
import (
"fmt"
"math"
"reflect"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/jesseduffield/asciigraph"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/mcuadros/go-lookup"
"github.com/samber/lo"
)
func RenderStats(userConfig *config.UserConfig, container *commands.Container, viewWidth int) (string, error) {
stats, ok := container.GetLastStats()
if !ok {
return "", nil
}
graphSpecs := userConfig.Stats.Graphs
graphs := make([]string, len(graphSpecs))
for i, spec := range graphSpecs {
graph, err := plotGraph(container, spec, viewWidth-10)
if err != nil {
return "", err
}
graphs[i] = utils.ColoredString(graph, utils.GetColorAttribute(spec.Color))
}
pidsCount := fmt.Sprintf("PIDs: %d", stats.ClientStats.PidsStats.Current)
dataReceived := fmt.Sprintf("Traffic received: %s", utils.FormatDecimalBytes(stats.ClientStats.Networks.Eth0.RxBytes))
dataSent := fmt.Sprintf("Traffic sent: %s", utils.FormatDecimalBytes(stats.ClientStats.Networks.Eth0.TxBytes))
originalStats, err := utils.MarshalIntoYaml(stats)
if err != nil {
return "", err
}
contents := fmt.Sprintf("\n\n%s\n\n%s\n\n%s\n%s\n\n%s",
utils.ColoredString(strings.Join(graphs, "\n\n"), color.FgGreen),
pidsCount,
dataReceived,
dataSent,
utils.ColoredYamlString(string(originalStats)),
)
return contents, nil
}
// plotGraph returns the plotted graph based on the graph spec and the stat history
func plotGraph(container *commands.Container, spec config.GraphConfig, width int) (string, error) {
container.StatsMutex.Lock()
defer container.StatsMutex.Unlock()
data := make([]float64, len(container.StatHistory))
for i, stats := range container.StatHistory {
value, err := lookup.LookupString(stats, spec.StatPath)
if err != nil {
return "Could not find key: " + spec.StatPath, nil
}
floatValue, err := getFloat(value.Interface())
if err != nil {
return "", err
}
data[i] = floatValue
}
max := spec.Max
if spec.MaxType == "" {
max = lo.Max(data)
}
min := spec.Min
if spec.MinType == "" {
min = lo.Min(data)
}
height := 10
if spec.Height > 0 {
height = spec.Height
}
caption := fmt.Sprintf(
"%s: %0.2f (%v)",
spec.Caption,
data[len(data)-1],
time.Since(container.StatHistory[0].RecordedAt).Round(time.Second),
)
return asciigraph.Plot(
data,
asciigraph.Height(height),
asciigraph.Width(width),
asciigraph.Min(min),
asciigraph.Max(max),
asciigraph.Caption(caption),
), nil
}
// from Dave C's answer at https://stackoverflow.com/questions/20767724/converting-unknown-interface-to-float64-in-golang
func getFloat(unk interface{}) (float64, error) {
floatType := reflect.TypeOf(float64(0))
stringType := reflect.TypeOf("")
switch i := unk.(type) {
case float64:
return i, nil
case float32:
return float64(i), nil
case int64:
return float64(i), nil
case int32:
return float64(i), nil
case int:
return float64(i), nil
case uint64:
return float64(i), nil
case uint32:
return float64(i), nil
case uint:
return float64(i), nil
case string:
return strconv.ParseFloat(i, 64)
default:
v := reflect.ValueOf(unk)
v = reflect.Indirect(v)
if v.Type().ConvertibleTo(floatType) {
fv := v.Convert(floatType)
return fv.Float(), nil
} else if v.Type().ConvertibleTo(stringType) {
sv := v.Convert(stringType)
s := sv.String()
return strconv.ParseFloat(s, 64)
} else {
return math.NaN(), fmt.Errorf("Can't convert %v to float64", v.Type())
}
}
}

View file

@ -1,202 +0,0 @@
package presentation
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/fatih/color"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
)
func GetContainerDisplayStrings(guiConfig *config.GuiConfig, container *commands.Container) []string {
return []string{
getContainerDisplayStatus(guiConfig, container),
getContainerDisplaySubstatus(guiConfig, container),
container.Name,
getDisplayCPUPerc(container),
utils.ColoredString(displayPorts(container), color.FgYellow),
utils.ColoredString(displayContainerImage(container), color.FgMagenta),
}
}
func displayContainerImage(container *commands.Container) string {
return strings.TrimPrefix(container.Container.Image, "sha256:")
}
func displayPorts(c *commands.Container) string {
portStrings := lo.Map(c.Container.Ports, func(port container.Port, _ int) string {
if port.PublicPort == 0 {
return fmt.Sprintf("%d/%s", port.PrivatePort, port.Type)
}
// docker ps will show '0.0.0.0:80->80/tcp' but we'll show
// '80->80/tcp' instead to save space (unless the IP is something other than
// 0.0.0.0)
ipString := ""
if port.IP != "0.0.0.0" {
ipString = port.IP + ":"
}
return fmt.Sprintf("%s%d->%d/%s", ipString, port.PublicPort, port.PrivatePort, port.Type)
})
// sorting because the order of the ports is not deterministic
// and we don't want to have them constantly swapping
sort.Strings(portStrings)
return strings.Join(portStrings, ", ")
}
// getContainerDisplayStatus returns the colored status of the container
func getContainerDisplayStatus(guiConfig *config.GuiConfig, c *commands.Container) string {
shortStatusMap := map[string]string{
"paused": "P",
"exited": "X",
"created": "C",
"removing": "RM",
"restarting": "RS",
"running": "R",
"dead": "D",
}
iconStatusMap := map[string]rune{
"paused": '◫',
"exited": '',
"created": '+',
"removing": '',
"restarting": '⟳',
"running": '▶',
"dead": '!',
}
var containerState string
switch guiConfig.ContainerStatusHealthStyle {
case "short":
containerState = shortStatusMap[c.Container.State]
case "icon":
containerState = string(iconStatusMap[c.Container.State])
case "long":
fallthrough
default:
containerState = c.Container.State
}
return utils.ColoredString(containerState, getContainerColor(c))
}
// GetDisplayStatus returns the exit code if the container has exited, and the health status if the container is running (and has a health check)
func getContainerDisplaySubstatus(guiConfig *config.GuiConfig, c *commands.Container) string {
if !c.DetailsLoaded() {
return ""
}
switch c.Container.State {
case "exited":
return utils.ColoredString(
fmt.Sprintf("(%s)", strconv.Itoa(c.Details.State.ExitCode)), getContainerColor(c),
)
case "running":
return getHealthStatus(guiConfig, c)
default:
return ""
}
}
func getHealthStatus(guiConfig *config.GuiConfig, c *commands.Container) string {
if !c.DetailsLoaded() {
return ""
}
healthStatusColorMap := map[string]color.Attribute{
"healthy": color.FgGreen,
"unhealthy": color.FgRed,
"starting": color.FgYellow,
}
if c.Details.State.Health == nil {
return ""
}
shortHealthStatusMap := map[string]string{
"healthy": "H",
"unhealthy": "U",
"starting": "S",
}
iconHealthStatusMap := map[string]rune{
"healthy": '✔',
"unhealthy": '?',
"starting": '…',
}
var healthStatus string
switch guiConfig.ContainerStatusHealthStyle {
case "short":
healthStatus = shortHealthStatusMap[c.Details.State.Health.Status]
case "icon":
healthStatus = string(iconHealthStatusMap[c.Details.State.Health.Status])
case "long":
fallthrough
default:
healthStatus = c.Details.State.Health.Status
}
if healthStatusColor, ok := healthStatusColorMap[c.Details.State.Health.Status]; ok {
return utils.ColoredString(fmt.Sprintf("(%s)", healthStatus), healthStatusColor)
}
return ""
}
// getDisplayCPUPerc colors the cpu percentage based on how extreme it is
func getDisplayCPUPerc(c *commands.Container) string {
stats, ok := c.GetLastStats()
if !ok {
return ""
}
percentage := stats.DerivedStats.CPUPercentage
formattedPercentage := fmt.Sprintf("%.2f%%", stats.DerivedStats.CPUPercentage)
var clr color.Attribute
if percentage > 90 {
clr = color.FgRed
} else if percentage > 50 {
clr = color.FgYellow
} else {
clr = color.FgWhite
}
return utils.ColoredString(formattedPercentage, clr)
}
// getContainerColor Container color
func getContainerColor(c *commands.Container) color.Attribute {
switch c.Container.State {
case "exited":
// This means the colour may be briefly yellow and then switch to red upon starting
// Not sure what a better alternative is.
if !c.DetailsLoaded() || c.Details.State.ExitCode == 0 {
return color.FgYellow
}
return color.FgRed
case "created":
return color.FgCyan
case "running":
return color.FgGreen
case "paused":
return color.FgYellow
case "dead":
return color.FgRed
case "restarting":
return color.FgBlue
case "removing":
return color.FgMagenta
default:
return color.FgWhite
}
}

View file

@ -1,14 +0,0 @@
package presentation
import (
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/utils"
)
func GetImageDisplayStrings(image *commands.Image) []string {
return []string{
image.Name,
image.Tag,
utils.FormatDecimalBytes(int(image.Image.Size)),
}
}

Some files were not shown because too many files have changed in this diff Show more