Merge pull request #392 from jesseduffield/list-panel-filtering

pull/401/head
Jesse Duffield 2 years ago committed by GitHub
commit 04cf34383d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,17 +7,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>e</kbd>: bearbeite lazydocker Konfiguration
<kbd>o</kbd>: öffne lazydocker Konfiguration
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<kbd>m</kbd>: zeige Protokolle
<kbd>enter</kbd>: fokussieren aufs Hauptpanel
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
</pre>
## Container
<pre>
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<kbd>d</kbd>: entfernen
<kbd>e</kbd>: hide/show stopped containers
<kbd>p</kbd>: pause
@ -30,6 +28,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<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
@ -45,36 +46,39 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>m</kbd>: zeige Protokolle
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<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>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<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>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab
<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>
## Haupt

@ -7,17 +7,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>e</kbd>: edit lazydocker config
<kbd>o</kbd>: open lazydocker config
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<kbd>m</kbd>: view logs
<kbd>enter</kbd>: focus main panel
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
</pre>
## Containers
<pre>
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<kbd>d</kbd>: remove
<kbd>e</kbd>: hide/show stopped containers
<kbd>p</kbd>: pause
@ -30,6 +28,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<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
@ -45,36 +46,39 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>m</kbd>: view logs
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<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>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<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>[</kbd>: previous tab
<kbd>]</kbd>: next tab
<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>
## Main

@ -7,17 +7,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>e</kbd>: modifier la configuration lazydocker
<kbd>o</kbd>: ouvrire la configuration lazydocker
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>m</kbd>: voir les enregistrements
<kbd>enter</kbd>: focus paneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
</pre>
## Conteneurs
<pre>
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>d</kbd>: supprimer
<kbd>e</kbd>: cacher/montrer les conteneurs arrêtés
<kbd>p</kbd>: pause
@ -30,6 +28,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>b</kbd>: voir les commandes groupés
<kbd>w</kbd>: ouvrir dans le navgateur (le premier port est http)
<kbd>enter</kbd>: focus paneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Services
@ -45,36 +46,39 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>m</kbd>: voir les enregistrements
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>R</kbd>: voir les options de redémarrage
<kbd>c</kbd>: executer une commande prédéfinie
<kbd>b</kbd>: voir les commandes groupés
<kbd>E</kbd>: executer le shell
<kbd>w</kbd>: ouvrir dans le navgateur (le premier port est http)
<kbd>enter</kbd>: focus paneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Images
<pre>
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>c</kbd>: executer une commande prédéfinie
<kbd>d</kbd>: supprimer l'image
<kbd>b</kbd>: voir les commandes groupés
<kbd>enter</kbd>: focus paneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Volumes
<pre>
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>c</kbd>: executer une commande prédéfinie
<kbd>d</kbd>: supprimer le volume
<kbd>b</kbd>: voir les commandes groupés
<kbd>enter</kbd>: focus paneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant
<kbd>/</kbd>: filter list
</pre>
## Principal

@ -7,17 +7,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>e</kbd>: verander de lazydocker configuratie
<kbd>o</kbd>: open de lazydocker configuratie
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<kbd>m</kbd>: bekijk logs
<kbd>enter</kbd>: focus hoofdpaneel
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
</pre>
## Containers
<pre>
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<kbd>d</kbd>: verwijder
<kbd>e</kbd>: verberg gestopte containers
<kbd>p</kbd>: pause
@ -30,6 +28,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<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
@ -45,36 +46,39 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>m</kbd>: bekijk logs
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<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>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<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>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab
<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>
## Hoofd

@ -7,17 +7,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>e</kbd>: edytuj konfigurację
<kbd>o</kbd>: otwórz konfigurację
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<kbd>m</kbd>: pokaż logi
<kbd>enter</kbd>: skup na głównym panelu
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
</pre>
## Kontenery
<pre>
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<kbd>d</kbd>: usuń
<kbd>e</kbd>: hide/show stopped containers
<kbd>p</kbd>: pause
@ -30,6 +28,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<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
@ -45,36 +46,39 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>m</kbd>: pokaż logi
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<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>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<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>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka
<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>
## Główne

@ -7,17 +7,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>e</kbd>: lazzydocker ayarlarını düzenle
<kbd>o</kbd>: lazydocker ayarlarını
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<kbd>m</kbd>: kayıt defterini görüntüle
<kbd>enter</kbd>: ana panele odaklan
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
</pre>
## Konteynerler
<pre>
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<kbd>d</kbd>: kaldır
<kbd>e</kbd>: hide/show stopped containers
<kbd>p</kbd>: pause
@ -30,6 +28,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<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
@ -45,36 +46,39 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>m</kbd>: kayıt defterini görüntüle
<kbd>U</kbd>: up project
<kbd>D</kbd>: down project
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<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>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<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>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme
<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>
## Ana

@ -13,15 +13,16 @@ require (
github.com/imdario/mergo v0.3.8
github.com/integrii/flaggy v1.4.0
github.com/jesseduffield/asciigraph v0.0.0-20190605104717-6d88e39309ee
github.com/jesseduffield/gocui v0.3.1-0.20220417002912-bce22fd599f6
github.com/jesseduffield/gocui v0.3.1-0.20221023185936-ef06450f4fdc
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10
github.com/jesseduffield/lazycore v0.0.0-20221010211550-2c30efd18b93
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.13
github.com/mattn/go-runewidth v0.0.14
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.4.2
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad
github.com/stretchr/testify v1.8.0
@ -35,7 +36,7 @@ require (
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/gdamore/tcell/v2 v2.5.1 // indirect
github.com/gdamore/tcell/v2 v2.5.3 // indirect
github.com/gogo/protobuf v1.3.1 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@ -47,14 +48,15 @@ require (
github.com/onsi/gomega v1.5.0 // indirect
github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
github.com/opencontainers/image-spec v1.0.1 // indirect
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rivo/uniseg v0.4.2 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
golang.org/x/exp v0.0.0-20220428152302-39d4317da171 // indirect
golang.org/x/net v0.0.0-20201021035429-f5854403a974 // indirect
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/term v0.1.0 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

@ -26,8 +26,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
github.com/gdamore/tcell/v2 v2.5.1 h1:zc3LPdpK184lBW7syF2a5C6MV827KmErk9jGVnmsl/I=
github.com/gdamore/tcell/v2 v2.5.1/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0=
github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
@ -48,12 +48,12 @@ 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.20220417002912-bce22fd599f6 h1:Fmay0Lz21taUpXiIbFkjjIIcn0E5GKwp5UFRuXaOiGQ=
github.com/jesseduffield/gocui v0.3.1-0.20220417002912-bce22fd599f6/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU=
github.com/jesseduffield/gocui v0.3.1-0.20221023185936-ef06450f4fdc h1:Gi/uDpmlB5XbyAzqr0RUFtK+2bVpUKkdR4soj/JKU1c=
github.com/jesseduffield/gocui v0.3.1-0.20221023185936-ef06450f4fdc/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU=
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-20221010211550-2c30efd18b93 h1:zY7ymCjXC7fZeJVXDpiicYx6c2YFgvsdZSOdch2f7gU=
github.com/jesseduffield/lazycore v0.0.0-20221010211550-2c30efd18b93/go.mod h1:qxN4mHOAyeIDLP7IK7defgPClM/z1Kze8VVQiaEjzsQ=
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/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
@ -71,8 +71,9 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/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=
@ -91,16 +92,21 @@ github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2i
github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
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.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
github.com/rivo/uniseg v0.4.2/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.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
@ -149,15 +155,16 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

@ -41,6 +41,7 @@ func generateAtDir(dir string) {
for lang := range i18n.GetTranslationSets() {
os.Setenv("LC_ALL", lang)
mApp, _ := app.NewApp(mConfig)
mApp.Gui.SetupFakeGui()
file, err := os.Create(dir + "/Keybindings_" + lang + ".md")
if err != nil {
@ -100,18 +101,6 @@ func getBindingSections(mApp *app.App) []*bindingSection {
bindingSections = addBinding(titleMap[viewName], bindingSections, binding)
}
// for view, contexts := range mApp.Gui.GetContextMap() {
// for contextName, contextBindings := range contexts {
// translatedView := localisedTitle(mApp, view)
// translatedContextName := localisedTitle(mApp, contextName)
// title := fmt.Sprintf("%s (%s)", translatedView, translatedContextName)
// for _, binding := range contextBindings {
// bindingSections = addBinding(title, bindingSections, binding)
// }
// }
// }
return bindingSections
}

@ -4,20 +4,15 @@ import (
"context"
"fmt"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"github.com/docker/docker/api/types/container"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
"github.com/docker/docker/api/types"
dockerTypes "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/config"
"github.com/jesseduffield/lazydocker/pkg/i18n"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/sirupsen/logrus"
@ -34,151 +29,21 @@ type Container struct {
OneOff bool
ProjectName string
ID string
Container types.Container
DisplayString string
Container dockerTypes.Container
Client *client.Client
OSCommand *OSCommand
Config *config.AppConfig
Log *logrus.Entry
StatHistory []*RecordedStats
Details types.ContainerJSON
Details dockerTypes.ContainerJSON
MonitoringStats bool
DockerCommand LimitedDockerCommand
Tr *i18n.TranslationSet
StatsMutex sync.Mutex
}
// GetDisplayStrings returns the dispaly string of Container
func (c *Container) GetDisplayStrings(isFocused bool) []string {
image := strings.TrimPrefix(c.Container.Image, "sha256:")
return []string{c.GetDisplayStatus(), c.GetDisplaySubstatus(), c.Name, c.GetDisplayCPUPerc(), utils.ColoredString(image, color.FgMagenta), c.displayPorts()}
}
func (c *Container) displayPorts() string {
portStrings := lo.Map(c.Container.Ports, func(port types.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, ", ")
}
// GetDisplayStatus returns the colored status of the container
func (c *Container) GetDisplayStatus() string {
return utils.ColoredString(c.Container.State, c.GetColor())
}
// 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 (c *Container) GetDisplaySubstatus() string {
if !c.DetailsLoaded() {
return ""
}
switch c.Container.State {
case "exited":
return utils.ColoredString(
fmt.Sprintf("(%s)", strconv.Itoa(c.Details.State.ExitCode)), c.GetColor(),
)
case "running":
return c.getHealthStatus()
default:
return ""
}
}
func (c *Container) getHealthStatus() 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 ""
}
healthStatus := c.Details.State.Health.Status
if healthStatusColor, ok := healthStatusColorMap[healthStatus]; ok {
return utils.ColoredString(fmt.Sprintf("(%s)", healthStatus), healthStatusColor)
}
return ""
}
// GetDisplayCPUPerc colors the cpu percentage based on how extreme it is
func (c *Container) GetDisplayCPUPerc() 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)
}
// ProducingLogs tells us whether we should bother checking a container's logs
func (c *Container) ProducingLogs() bool {
return c.Container.State == "running" && c.DetailsLoaded() && c.Details.HostConfig.LogConfig.Type != "none"
}
// GetColor Container color
func (c *Container) GetColor() 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
}
StatsMutex deadlock.Mutex
}
// Remove removes the container
func (c *Container) Remove(options types.ContainerRemoveOptions) error {
func (c *Container) Remove(options dockerTypes.ContainerRemoveOptions) error {
c.Log.Warn(fmt.Sprintf("removing container %s", c.Name))
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") {
@ -240,7 +105,7 @@ func (c *Container) Attach() (*exec.Cmd, error) {
}
// Top returns process information
func (c *Container) Top() (container.ContainerTopOKBody, error) {
func (c *Container) Top(ctx context.Context) (container.ContainerTopOKBody, error) {
detail, err := c.Inspect()
if err != nil {
return container.ContainerTopOKBody{}, err
@ -251,7 +116,7 @@ func (c *Container) Top() (container.ContainerTopOKBody, error) {
return container.ContainerTopOKBody{}, errors.New("container is not running")
}
return c.Client.ContainerTop(context.Background(), c.ID, []string{})
return c.Client.ContainerTop(ctx, c.ID, []string{})
}
// PruneContainers prunes containers
@ -261,13 +126,13 @@ func (c *DockerCommand) PruneContainers() error {
}
// Inspect returns details about the container
func (c *Container) Inspect() (types.ContainerJSON, error) {
func (c *Container) Inspect() (dockerTypes.ContainerJSON, error) {
return c.Client.ContainerInspect(context.Background(), c.ID)
}
// RenderTop returns details about the container
func (c *Container) RenderTop() (string, error) {
result, err := c.Top()
func (c *Container) RenderTop(ctx context.Context) (string, error) {
result, err := c.Top(ctx)
if err != nil {
return "", err
}

@ -1,19 +1,8 @@
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
@ -166,66 +155,29 @@ func (s *ContainerStats) CalculateContainerMemoryUsage() float64 {
return value
}
// RenderStats returns a string containing the rendered stats of the container
func (c *Container) RenderStats(viewWidth int) (string, error) {
stats, ok := c.getLastStats()
if !ok {
return "", nil
}
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))
}
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))
originalJSON, err := json.MarshalIndent(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,
string(originalJSON),
)
return contents, nil
}
func (c *Container) appendStats(stats *RecordedStats) {
func (c *Container) appendStats(stats *RecordedStats, maxDuration time.Duration) {
c.StatsMutex.Lock()
defer c.StatsMutex.Unlock()
c.StatHistory = append(c.StatHistory, stats)
c.eraseOldHistory()
c.eraseOldHistory(maxDuration)
}
// eraseOldHistory removes any history before the user-specified max duration
func (c *Container) eraseOldHistory() {
if c.Config.UserConfig.Stats.MaxDuration == 0 {
func (c *Container) eraseOldHistory(maxDuration time.Duration) {
if maxDuration == 0 {
return
}
for i, stat := range c.StatHistory {
if time.Since(stat.RecordedAt) < c.Config.UserConfig.Stats.MaxDuration {
if time.Since(stat.RecordedAt) < maxDuration {
c.StatHistory = c.StatHistory[i:]
return
}
}
}
func (c *Container) getLastStats() (*RecordedStats, bool) {
func (c *Container) GetLastStats() (*RecordedStats, bool) {
c.StatsMutex.Lock()
defer c.StatsMutex.Unlock()
history := c.StatHistory
@ -234,95 +186,3 @@ func (c *Container) getLastStats() (*RecordedStats, bool) {
}
return history[len(history)-1], true
}
// 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) {
c.StatsMutex.Lock()
defer c.StatsMutex.Unlock()
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
}
height := 10
if spec.Height > 0 {
height = spec.Height
}
return asciigraph.Plot(
data,
asciigraph.Height(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).Round(time.Second))),
), 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())
}
}
}

@ -8,19 +8,17 @@ import (
"io"
ogLog "log"
"os/exec"
"sort"
"strings"
"sync"
"time"
"github.com/docker/docker/api/types"
dockerTypes "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/samber/lo"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
@ -36,17 +34,11 @@ type DockerCommand struct {
Config *config.AppConfig
Client *client.Client
InDockerComposeProject bool
ShowExited bool
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
Closers []io.Closer
ContainerMutex deadlock.Mutex
ServiceMutex deadlock.Mutex
Closers []io.Closer
}
var _ io.Closer = &DockerCommand{}
@ -91,7 +83,6 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat
Config: config,
Client: cli,
ErrorChan: errorChan,
ShowExited: true,
InDockerComposeProject: true,
Closers: []io.Closer{tunnelCloser},
}
@ -121,26 +112,7 @@ func (c *DockerCommand) Close() error {
return utils.CloseMany(c.Closers)
}
func (c *DockerCommand) 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 c.Containers {
if !container.MonitoringStats {
go c.createClientStatMonitor(container)
}
}
}
}
}
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 {
@ -168,22 +140,19 @@ func (c *DockerCommand) createClientStatMonitor(container *Container) {
RecordedAt: time.Now(),
}
container.appendStats(recordedStats)
container.appendStats(recordedStats, c.Config.UserConfig.Stats.MaxDuration)
}
container.MonitoringStats = false
}
// RefreshContainersAndServices returns a slice of docker containers
func (c *DockerCommand) RefreshContainersAndServices() error {
func (c *DockerCommand) RefreshContainersAndServices(currentServices []*Service, currentContainers []*Container) ([]*Container, []*Service, error) {
c.ServiceMutex.Lock()
defer c.ServiceMutex.Unlock()
currentServices := c.Services
containers, err := c.GetContainers()
containers, err := c.GetContainers(currentContainers)
if err != nil {
return err
return nil, nil, err
}
var services []*Service
@ -193,38 +162,13 @@ func (c *DockerCommand) RefreshContainersAndServices() error {
} else {
services, err = c.GetServices()
if err != nil {
return err
return nil, nil, err
}
}
c.assignContainersToServices(containers, services)
displayContainers := containers
if !c.Config.UserConfig.Gui.ShowAllContainers {
displayContainers = c.obtainStandaloneContainers(containers, services)
}
// 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
}
if services[i].Container == nil && services[j].Container != nil {
return false
}
return services[i].Name < services[j].Name
})
c.Containers = containers
c.Services = services
c.Services = c.filterOutIgnoredServices(c.Services)
c.DisplayContainers = c.filterOutExited(displayContainers)
c.DisplayContainers = c.filterOutIgnoredContainers(c.DisplayContainers)
c.DisplayContainers = c.sortedContainers(c.DisplayContainers)
return nil
return containers, services, nil
}
func (c *DockerCommand) assignContainersToServices(containers []*Container, services []*Service) {
@ -240,81 +184,12 @@ L:
}
}
// filterOutExited filters out the exited containers if c.ShowExited is false
func (c *DockerCommand) filterOutExited(containers []*Container) []*Container {
if c.ShowExited {
return containers
}
toReturn := []*Container{}
for _, container := range containers {
if container.Container.State != "exited" {
toReturn = append(toReturn, container)
}
}
return toReturn
}
func (c *DockerCommand) filterOutIgnoredContainers(containers []*Container) []*Container {
return lo.Filter(containers, func(container *Container, _ int) bool {
return !lo.SomeBy(c.Config.UserConfig.Ignore, func(ignore string) bool {
return strings.Contains(container.Name, ignore)
})
})
}
func (c *DockerCommand) filterOutIgnoredServices(services []*Service) []*Service {
return lo.Filter(services, func(service *Service, _ int) bool {
return !lo.SomeBy(c.Config.UserConfig.Ignore, func(ignore string) bool {
return strings.Contains(service.Name, ignore)
})
})
}
// 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
func (c *DockerCommand) sortedContainers(containers []*Container) []*Container {
if !c.Config.UserConfig.Gui.LegacySortContainers {
states := map[string]int{
"running": 1,
"exited": 2,
"created": 3,
}
sort.Slice(containers, func(i, j int) bool {
stateLeft := states[containers[i].Container.State]
stateRight := states[containers[j].Container.State]
if stateLeft == stateRight {
return containers[i].Name < containers[j].Name
}
return states[containers[i].Container.State] < states[containers[j].Container.State]
})
}
return containers
}
// obtainStandaloneContainers returns standalone containers. Standalone containers are containers which are either one-off containers, or whose service is not part of this docker-compose context
func (c *DockerCommand) obtainStandaloneContainers(containers []*Container, services []*Service) []*Container {
standaloneContainers := []*Container{}
L:
for _, container := range containers {
for _, service := range services {
if !container.OneOff && container.ServiceName != "" && container.ServiceName == service.Name {
continue L
}
}
standaloneContainers = append(standaloneContainers, container)
}
return standaloneContainers
}
// GetContainers gets the docker containers
func (c *DockerCommand) GetContainers() ([]*Container, error) {
func (c *DockerCommand) GetContainers(existingContainers []*Container) ([]*Container, error) {
c.ContainerMutex.Lock()
defer c.ContainerMutex.Unlock()
existingContainers := c.Containers
containers, err := c.Client.ContainerList(context.Background(), types.ContainerListOptions{All: true})
containers, err := c.Client.ContainerList(context.Background(), dockerTypes.ContainerListOptions{All: true})
if err != nil {
return nil, err
}
@ -339,7 +214,6 @@ func (c *DockerCommand) GetContainers() ([]*Container, error) {
Client: c.Client,
OSCommand: c.OSCommand,
Log: c.Log,
Config: c.Config,
DockerCommand: c,
Tr: c.Tr,
}
@ -397,11 +271,11 @@ func (c *DockerCommand) GetServices() ([]*Service, 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 {
func (c *DockerCommand) UpdateContainerDetails(containers []*Container) error {
c.ContainerMutex.Lock()
defer c.ContainerMutex.Unlock()
for _, container := range c.Containers {
for _, container := range containers {
details, err := c.Client.ContainerInspect(context.Background(), container.ID)
if err != nil {
c.Log.Error(err)

@ -2,13 +2,12 @@ package commands
import (
"context"
"sort"
"strings"
"github.com/docker/docker/api/types/image"
"github.com/samber/lo"
"github.com/docker/docker/api/types"
dockerTypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/fatih/color"
@ -21,20 +20,15 @@ type Image struct {
Name string
Tag string
ID string
Image types.ImageSummary
Image dockerTypes.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{i.Name, i.Tag, utils.FormatDecimalBytes(int(i.Image.Size))}
}
// Remove removes the image
func (i *Image) Remove(options types.ImageRemoveOptions) error {
func (i *Image) Remove(options dockerTypes.ImageRemoveOptions) error {
if _, err := i.Client.ImageRemove(context.Background(), i.ID, options); err != nil {
return err
}
@ -42,19 +36,13 @@ func (i *Image) Remove(options types.ImageRemoveOptions) error {
return nil
}
// Layer is a layer in an image's history
type Layer struct {
image.HistoryResponseItem
}
// GetDisplayStrings returns the array of strings describing the layer
func (l *Layer) GetDisplayStrings(isFocused bool) []string {
func getHistoryResponseItemDisplayStrings(layer image.HistoryResponseItem) []string {
tag := ""
if len(l.Tags) > 0 {
tag = l.Tags[0]
if len(layer.Tags) > 0 {
tag = layer.Tags[0]
}
id := strings.TrimPrefix(l.ID, "sha256:")
id := strings.TrimPrefix(layer.ID, "sha256:")
if len(id) > 10 {
id = id[0:10]
}
@ -64,16 +52,16 @@ func (l *Layer) GetDisplayStrings(isFocused bool) []string {
}
dockerFileCommandPrefix := "/bin/sh -c #(nop) "
createdBy := l.CreatedBy
if strings.Contains(l.CreatedBy, dockerFileCommandPrefix) {
createdBy = strings.Trim(strings.TrimPrefix(l.CreatedBy, dockerFileCommandPrefix), " ")
createdBy := layer.CreatedBy
if strings.Contains(layer.CreatedBy, dockerFileCommandPrefix) {
createdBy = strings.Trim(strings.TrimPrefix(layer.CreatedBy, dockerFileCommandPrefix), " ")
split := strings.Split(createdBy, " ")
createdBy = utils.ColoredString(split[0], color.FgYellow) + " " + strings.Join(split[1:], " ")
}
createdBy = strings.Replace(createdBy, "\t", " ", -1)
size := utils.FormatBinaryBytes(int(l.Size))
size := utils.FormatBinaryBytes(int(layer.Size))
sizeColor := color.FgWhite
if size == "0B" {
sizeColor = color.FgBlue
@ -94,17 +82,19 @@ func (i *Image) RenderHistory() (string, error) {
return "", err
}
layers := make([]*Layer, len(history))
for i, layer := range history {
layers[i] = &Layer{layer}
}
tableBody := lo.Map(history, func(layer image.HistoryResponseItem, _ int) []string {
return getHistoryResponseItemDisplayStrings(layer)
})
return utils.RenderList(layers, utils.WithHeader([]string{"ID", "TAG", "SIZE", "COMMAND"}))
headers := [][]string{{"ID", "TAG", "SIZE", "COMMAND"}}
table := append(headers, tableBody...)
return utils.RenderTable(table)
}
// RefreshImages returns a slice of docker images
func (c *DockerCommand) RefreshImages() ([]*Image, error) {
images, err := c.Client.ImageList(context.Background(), types.ImageListOptions{})
images, err := c.Client.ImageList(context.Background(), dockerTypes.ImageListOptions{})
if err != nil {
return nil, err
}
@ -145,26 +135,6 @@ func (c *DockerCommand) RefreshImages() ([]*Image, error) {
}
}
ownImages = lo.Filter(ownImages, func(image *Image, _ int) bool {
return !lo.SomeBy(c.Config.UserConfig.Ignore, func(ignore string) bool {
return strings.Contains(image.Name, ignore)
})
})
noneLabel := "<none>"
sort.Slice(ownImages, func(i, j int) bool {
if ownImages[i].Name == noneLabel && ownImages[j].Name != noneLabel {
return false
}
if ownImages[i].Name != noneLabel && ownImages[j].Name == noneLabel {
return true
}
return ownImages[i].Name < ownImages[j].Name
})
return ownImages, nil
}

@ -1,6 +1,7 @@
package commands
import (
"context"
"fmt"
"io/ioutil"
"os"
@ -65,6 +66,15 @@ func (c *OSCommand) RunCommandWithOutput(command string) (string, error) {
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())
c.Log.Warn(fmt.Sprintf("'%s': %s", command, time.Since(before)))
return output, err
}
// RunExecutableWithOutput runs an executable file and returns its output
func (c *OSCommand) RunExecutableWithOutput(cmd *exec.Cmd) (string, error) {
return sanitisedCommandOutput(cmd.CombinedOutput())
@ -79,10 +89,15 @@ func (c *OSCommand) RunExecutable(cmd *exec.Cmd) error {
// ExecutableFromString takes a string like `docker ps -a` and returns an executable command for it
func (c *OSCommand) ExecutableFromString(commandStr string) *exec.Cmd {
splitCmd := str.ToArgv(commandStr)
// 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:]...)
}
// RunCommand runs a command and just returns the error
func (c *OSCommand) RunCommand(command string) error {
_, err := c.RunCommandWithOutput(command)

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

@ -1,12 +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"
dockerTypes "github.com/docker/docker/api/types"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/sirupsen/logrus"
)
@ -21,18 +19,8 @@ type Service struct {
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.FgBlue), "", s.Name, "", ""}
}
cont := s.Container
return []string{cont.GetDisplayStatus(), cont.GetDisplaySubstatus(), s.Name, cont.GetDisplayCPUPerc(), utils.ColoredString(cont.displayPorts(), color.FgYellow)}
}
// Remove removes the service's containers
func (s *Service) Remove(options types.ContainerRemoveOptions) error {
func (s *Service) Remove(options dockerTypes.ContainerRemoveOptions) error {
return s.Container.Remove(options)
}
@ -69,11 +57,6 @@ func (s *Service) Attach() (*exec.Cmd, error) {
return s.Container.Attach()
}
// Top returns process information
func (s *Service) Top() (container.ContainerTopOKBody, 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
@ -89,12 +72,12 @@ func (s *Service) ViewLogs() (*exec.Cmd, error) {
}
// RenderTop renders the process list of the service
func (s *Service) RenderTop() (string, error) {
func (s *Service) RenderTop(ctx context.Context) (string, error) {
templateString := s.OSCommand.Config.UserConfig.CommandTemplates.ServiceTop
command := utils.ApplyTemplate(
templateString,
s.DockerCommand.NewCommandObject(CommandObject{Service: s}),
)
return s.OSCommand.RunCommandWithOutput(command)
return s.OSCommand.RunCommandWithOutputContext(ctx, command)
}

@ -1,173 +0,0 @@
package commands
import (
"testing"
"github.com/docker/docker/api/types"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/stretchr/testify/assert"
)
func sampleContainers(userConfig *config.AppConfig) []*Container {
return []*Container{
{
ID: "1",
Name: "1",
Container: types.Container{
State: "exited",
},
Config: userConfig,
},
{
ID: "2",
Name: "2",
Container: types.Container{
State: "running",
},
Config: userConfig,
},
{
ID: "3",
Name: "3",
Container: types.Container{
State: "running",
},
Config: userConfig,
},
{
ID: "4",
Name: "4",
Container: types.Container{
State: "created",
},
Config: userConfig,
},
}
}
func expectedPerStatusContainers(appConfig *config.AppConfig) []*Container {
return []*Container{
{
ID: "2",
Name: "2",
Container: types.Container{
State: "running",
},
Config: appConfig,
},
{
ID: "3",
Name: "3",
Container: types.Container{
State: "running",
},
Config: appConfig,
},
{
ID: "1",
Name: "1",
Container: types.Container{
State: "exited",
},
Config: appConfig,
},
{
ID: "4",
Name: "4",
Container: types.Container{
State: "created",
},
Config: appConfig,
},
}
}
func expectedLegacySortedContainers(appConfig *config.AppConfig) []*Container {
return []*Container{
{
ID: "1",
Name: "1",
Container: types.Container{
State: "exited",
},
Config: appConfig,
},
{
ID: "2",
Name: "2",
Container: types.Container{
State: "running",
},
Config: appConfig,
},
{
ID: "3",
Name: "3",
Container: types.Container{
State: "running",
},
Config: appConfig,
},
{
ID: "4",
Name: "4",
Container: types.Container{
State: "created",
},
Config: appConfig,
},
}
}
func assertEqualContainers(t *testing.T, left *Container, right *Container) {
t.Helper()
assert.Equal(t, left.Container.State, right.Container.State)
assert.Equal(t, left.Container.ID, right.Container.ID)
assert.Equal(t, left.Name, right.Name)
}
func TestSortContainers(t *testing.T) {
appConfig := NewDummyAppConfig()
appConfig.UserConfig = &config.UserConfig{
Gui: config.GuiConfig{
LegacySortContainers: false,
},
}
command := &DockerCommand{
Config: appConfig,
}
containers := sampleContainers(appConfig)
sorted := expectedPerStatusContainers(appConfig)
ct := command.sortedContainers(containers)
assert.Equal(t, len(ct), len(sorted))
for i := 0; i < len(ct); i++ {
assertEqualContainers(t, sorted[i], ct[i])
}
}
func TestLegacySortedContainers(t *testing.T) {
appConfig := NewDummyAppConfig()
appConfig.UserConfig = &config.UserConfig{
Gui: config.GuiConfig{
LegacySortContainers: true,
},
}
command := &DockerCommand{
Config: appConfig,
}
containers := sampleContainers(appConfig)
sorted := expectedLegacySortedContainers(appConfig)
ct := command.sortedContainers(containers)
for i := 0; i < len(ct); i++ {
assertEqualContainers(t, sorted[i], ct[i])
}
}

@ -2,55 +2,34 @@ package commands
import (
"context"
"sort"
"strings"
"github.com/docker/docker/api/types"
dockerTypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
// Volume : A docker Volume
type Volume struct {
Name string
Volume *types.Volume
Volume *dockerTypes.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() error {
func (c *DockerCommand) RefreshVolumes() ([]*Volume, error) {
result, err := c.Client.VolumeList(context.Background(), filters.Args{})
if err != nil {
return err
return nil, err
}
volumes := result.Volumes
ownVolumes := make([]*Volume, len(volumes))
// we're sorting these volumes based on whether they have labels defined,
// because those are the ones you typically care about.
// Within that, we also sort them alphabetically
sort.Slice(volumes, func(i, j int) bool {
if len(volumes[i].Labels) == 0 && len(volumes[j].Labels) > 0 {
return false
}
if len(volumes[i].Labels) > 0 && len(volumes[j].Labels) == 0 {
return true
}
return volumes[i].Name < volumes[j].Name
})
for i, volume := range volumes {
ownVolumes[i] = &Volume{
Name: volume.Name,
@ -62,15 +41,7 @@ func (c *DockerCommand) RefreshVolumes() error {
}
}
ownVolumes = lo.Filter(ownVolumes, func(volume *Volume, _ int) bool {
return !lo.SomeBy(c.Config.UserConfig.Ignore, func(ignore string) bool {
return strings.Contains(volume.Name, ignore)
})
})
c.Volumes = ownVolumes
return nil
return ownVolumes, nil
}
// PruneVolumes prunes volumes

@ -2,6 +2,7 @@ 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"
@ -28,7 +29,7 @@ func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map
sidePanelsDirection = boxlayout.ROW
}
showInfoSection := gui.Config.UserConfig.Gui.ShowBottomLine
showInfoSection := gui.Config.UserConfig.Gui.ShowBottomLine || gui.State.Filter.active
infoSectionSize := 0
if showInfoSection {
infoSectionSize = 1
@ -98,6 +99,19 @@ func (gui *Gui) infoSectionChildren(informationStr string, appStatus string) []*
)
}
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{
{
@ -116,11 +130,13 @@ func (gui *Gui) infoSectionChildren(informationStr string, appStatus string) []*
}
func (gui *Gui) sideViewNames() []string {
if gui.DockerCommand.InDockerComposeProject {
return []string{"project", "services", "containers", "images", "volumes"}
} else {
return []string{"project", "containers", "images", "volumes"}
}
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 {

@ -52,8 +52,8 @@ func (gui *Gui) getMessageHeight(wrap bool, message string, width int) int {
return lineCount
}
func (gui *Gui) getConfirmationPanelDimensions(g *gocui.Gui, wrap bool, prompt string) (int, int, int, int) {
width, height := g.Size()
func (gui *Gui) getConfirmationPanelDimensions(wrap bool, prompt string) (int, int, int, int) {
width, height := gui.g.Size()
panelWidth := width / 2
panelHeight := gui.getMessageHeight(wrap, prompt, panelWidth)
return width/2 - panelWidth/2,
@ -73,7 +73,7 @@ func (gui *Gui) createPromptPanel(title string, handleConfirm func(*gocui.Gui, *
}
func (gui *Gui) prepareConfirmationPanel(title, prompt string, hasLoader bool) error {
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(gui.g, true, prompt)
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(true, prompt)
confirmationView := gui.Views.Confirmation
_, err := gui.g.SetView("confirmation", x0, y0, x1, y1, 0)
if err != nil {
@ -126,17 +126,17 @@ func (gui *Gui) createPopupPanel(title, prompt string, hasLoader bool, handleCon
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", nil, gocui.KeyEnter, gocui.ModNone, gui.wrappedConfirmationFunction(handleConfirm)); err != nil {
if err := g.SetKeybinding("confirmation", gocui.KeyEnter, gocui.ModNone, gui.wrappedConfirmationFunction(handleConfirm)); err != nil {
return err
}
if err := g.SetKeybinding("confirmation", nil, 'y', gocui.ModNone, gui.wrappedConfirmationFunction(handleConfirm)); err != nil {
if err := g.SetKeybinding("confirmation", 'y', gocui.ModNone, gui.wrappedConfirmationFunction(handleConfirm)); err != nil {
return err
}
if err := g.SetKeybinding("confirmation", nil, gocui.KeyEsc, gocui.ModNone, gui.wrappedConfirmationFunction(handleClose)); err != nil {
if err := g.SetKeybinding("confirmation", gocui.KeyEsc, gocui.ModNone, gui.wrappedConfirmationFunction(handleClose)); err != nil {
return err
}
if err := g.SetKeybinding("confirmation", nil, 'n', gocui.ModNone, gui.wrappedConfirmationFunction(handleClose)); err != nil {
if err := g.SetKeybinding("confirmation", 'n', gocui.ModNone, gui.wrappedConfirmationFunction(handleClose)); err != nil {
return err
}

@ -8,36 +8,34 @@ import (
"os/signal"
"time"
"github.com/docker/docker/api/types"
dockerTypes "github.com/docker/docker/api/types"
"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) error {
mainView := gui.getMainView()
mainView.Autoscroll = true
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
return gui.T.NewTickerTask(time.Millisecond*200, nil, func(stop, notifyStopped chan struct{}) {
gui.renderContainerLogsToMainAux(container, stop, notifyStopped)
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, stop, notifyStopped chan struct{}) {
func (gui *Gui) renderContainerLogsToMainAux(container *commands.Container, ctx context.Context, notifyStopped chan struct{}) {
gui.clearMainView()
defer func() {
notifyStopped <- struct{}{}
}()
ctx, ctxCancel := context.WithCancel(context.Background())
go func() {
<-stop
ctxCancel()
}()
mainView := gui.getMainView()
mainView := gui.Views.Main
if err := gui.writeContainerLogs(container, ctx, mainView); err != nil {
gui.Log.Error(err)
@ -49,7 +47,7 @@ func (gui *Gui) renderContainerLogsToMainAux(container *commands.Container, stop
defer ticker.Stop()
for {
select {
case <-stop:
case <-ctx.Done():
return
case <-ticker.C:
result, err := container.Inspect()
@ -107,7 +105,7 @@ func (gui *Gui) promptToReturn() {
}
func (gui *Gui) writeContainerLogs(container *commands.Container, ctx context.Context, writer io.Writer) error {
readCloser, err := gui.DockerCommand.Client.ContainerLogs(ctx, container.ID, types.ContainerLogsOptions{
readCloser, err := gui.DockerCommand.Client.ContainerLogs(ctx, container.ID, dockerTypes.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Timestamps: gui.Config.UserConfig.Logs.Timestamps,

@ -1,146 +1,170 @@
package gui
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/docker/docker/api/types"
dockerTypes "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"
)
// list panel functions
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
}
func (gui *Gui) getContainerContexts() []string {
return []string{"logs", "stats", "env", "config", "top"}
}
return !lo.SomeBy(gui.Panels.Services.List.GetAllItems(), func(service *commands.Service) bool {
return service.Name == container.ServiceName
})
}
func (gui *Gui) getContainerContextTitles() []string {
return []string{gui.Tr.LogsTitle, gui.Tr.StatsTitle, gui.Tr.EnvTitle, gui.Tr.ConfigTitle, gui.Tr.TopTitle}
}
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 {
// 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
}
func (gui *Gui) getSelectedContainer() (*commands.Container, error) {
selectedLine := gui.State.Panels.Containers.SelectedLine
if selectedLine == -1 {
return &commands.Container{}, gui.Errors.ErrNoContainers
}
if !gui.State.ShowExitedContainers && container.Container.State == "exited" {
return false
}
return gui.DockerCommand.DisplayContainers[selectedLine], nil
return true
},
GetTableCells: presentation.GetContainerDisplayStrings,
}
}
func (gui *Gui) handleContainersClick(g *gocui.Gui, v *gocui.View) error {
itemCount := len(gui.DockerCommand.DisplayContainers)
handleSelect := gui.handleContainerSelect
selectedLine := &gui.State.Panels.Containers.SelectedLine
return gui.handleClick(v, itemCount, selectedLine, handleSelect)
var containerStates = map[string]int{
"running": 1,
"exited": 2,
"created": 3,
}
func (gui *Gui) handleContainerSelect(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
if err != nil {
if err != gui.Errors.ErrNoContainers {
return err
}
return nil
func sortContainers(a *commands.Container, b *commands.Container, legacySort bool) bool {
if legacySort {
return a.Name < b.Name
}
gui.focusY(gui.State.Panels.Containers.SelectedLine, len(gui.DockerCommand.DisplayContainers), v)
key := "containers-" + container.ID + "-" + gui.getContainerContexts()[gui.State.Panels.Containers.ContextIndex]
if !gui.shouldRefresh(key) {
return nil
stateLeft := containerStates[a.Container.State]
stateRight := containerStates[b.Container.State]
if stateLeft == stateRight {
return a.Name < b.Name
}
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.renderContainerLogsToMain(container); err != nil {
return err
}
case "config":
if err := gui.renderContainerConfig(container); err != nil {
return err
}
case "env":
if err := gui.renderContainerEnv(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 containerStates[a.Container.State] < containerStates[b.Container.State]
}
return nil
func (gui *Gui) renderContainerEnv(container *commands.Container) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string { return gui.containerEnv(container) })
}
func (gui *Gui) renderContainerEnv(container *commands.Container) error {
func (gui *Gui) containerEnv(container *commands.Container) string {
if !container.DetailsLoaded() {
return gui.T.NewTask(func(stop chan struct{}) {
_ = gui.renderString(gui.g, "main", gui.Tr.WaitingForContainerInfo)
})
return gui.Tr.WaitingForContainerInfo
}
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
envVariablesList := [][]string{}
renderedTable := gui.Tr.NothingToDisplay
if len(container.Details.Config.Env) > 0 {
var err error
for _, env := range container.Details.Config.Env {
splitEnv := strings.SplitN(env, "=", 2)
key := splitEnv[0]
value := ""
if len(splitEnv) > 1 {
value = splitEnv[1]
}
envVariablesList = append(envVariablesList,
[]string{
utils.ColoredString(key+":", color.FgGreen),
utils.ColoredString(value, color.FgYellow),
})
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]
}
renderedTable, err = utils.RenderTable(envVariablesList)
if err != nil {
gui.Log.Error(err)
renderedTable = gui.Tr.CannotDisplayEnvVariables
return []string{
utils.ColoredString(key+":", color.FgGreen),
utils.ColoredString(value, color.FgYellow),
}
}
return gui.T.NewTask(func(stop chan struct{}) {
_ = gui.renderString(gui.g, "main", renderedTable)
})
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) renderContainerConfig(container *commands.Container) error {
func (gui *Gui) containerConfigStr(container *commands.Container) string {
if !container.DetailsLoaded() {
return gui.T.NewTask(func(stop chan struct{}) {
_ = gui.renderString(gui.g, "main", gui.Tr.WaitingForContainerInfo)
})
return gui.Tr.WaitingForContainerInfo
}
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
padding := 10
output := ""
output += utils.WithPadding("ID: ", padding) + container.ID + "\n"
@ -178,233 +202,112 @@ func (gui *Gui) renderContainerConfig(container *commands.Container) error {
data, err := json.MarshalIndent(&container.Details, "", " ")
if err != nil {
return err
return fmt.Sprintf("Error marshalling container details: %v", err)
}
output += fmt.Sprintf("\nFull details:\n\n%s", string(data))
return gui.T.NewTask(func(stop chan struct{}) {
_ = gui.renderString(gui.g, "main", output)
})
return output
}
func (gui *Gui) renderContainerStats(container *commands.Container) error {
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
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(err.Error())
}
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)
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) error {
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
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.reRenderStringMain(err.Error())
}
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)
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 {
containersView := gui.getContainersView()
if containersView == nil {
if gui.Views.Containers == 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]
}
originalSelectedLineIdx := gui.Panels.Services.SelectedIdx
selectedService, isServiceSelected := gui.Panels.Services.List.TryGet(originalSelectedLineIdx)
if err := gui.DockerCommand.RefreshContainersAndServices(); err != nil {
containers, services, err := gui.DockerCommand.RefreshContainersAndServices(
gui.Panels.Services.List.GetAllItems(),
gui.Panels.Containers.List.GetAllItems(),
)
if err != nil {
return err
}
gui.Panels.Services.SetItems(services)
gui.Panels.Containers.SetItems(containers)
// see if our selected service has moved
if selectedService != nil {
for i, service := range gui.DockerCommand.Services {
if isServiceSelected {
for i, service := range gui.Panels.Services.List.GetItems() {
if service.ID == selectedService.ID {
if i == sl {
if i == originalSelectedLineIdx {
break
}
gui.State.Panels.Services.SelectedLine = i
gui.focusY(i, len(gui.DockerCommand.Services), gui.getServicesView())
gui.Panels.Services.SetSelectedLineIdx(i)
gui.Panels.Services.Refocus()
}
}
}
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.renderContainersAndServices()
return nil
return gui.renderContainersAndServices()
}
func (gui *Gui) renderContainersAndServices() {
gui.g.Update(func(g *gocui.Gui) error {
containersView := gui.getContainersView()
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 {
func (gui *Gui) renderContainersAndServices() error {
if gui.DockerCommand.InDockerComposeProject {
if err := gui.Panels.Services.RerenderList(); err != nil {
return err
}
fmt.Fprint(servicesView, list)
if servicesView == g.CurrentView() {
return gui.handleServiceSelect(g, servicesView)
}
return nil
})
}
func (gui *Gui) handleContainersNextLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() || gui.g.CurrentView() != v {
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() || gui.g.CurrentView() != v {
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--
if err := gui.Panels.Containers.RerenderList(); err != nil {
return err
}
_ = gui.handleContainerSelect(gui.g, v)
return nil
}
type removeContainerOption struct {
description string
command string
configOptions types.ContainerRemoveOptions
}
// 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) handleHideStoppedContainers(g *gocui.Gui, v *gocui.View) error {
gui.DockerCommand.ShowExited = !gui.DockerCommand.ShowExited
if err := gui.refreshContainersAndServices(); err != nil {
return err
}
gui.State.ShowExitedContainers = !gui.State.ShowExitedContainers
return nil
return gui.Panels.Containers.RerenderList()
}
func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
container, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
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
handleMenuPress := func(configOptions dockerTypes.ContainerRemoveOptions) error {
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
if err := container.Remove(configOptions); err != nil {
if commands.HasErrorCode(err, commands.MustStopContainer) {
@ -421,7 +324,21 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error {
})
}
return gui.createMenu("", options, len(options), handleMenuPress)
menuItems := []*types.MenuItem{
{
LabelColumns: []string{gui.Tr.Remove, "docker rm " + container.ID[1:10]},
OnPress: func() error { return handleMenuPress(dockerTypes.ContainerRemoveOptions{}) },
},
{
LabelColumns: []string{gui.Tr.RemoveWithVolumes, "docker rm --volumes " + container.ID[1:10]},
OnPress: func() error { return handleMenuPress(dockerTypes.ContainerRemoveOptions{RemoveVolumes: true}) },
},
}
return gui.Menu(CreateMenuOptions{
Title: "",
Items: menuItems,
})
}
func (gui *Gui) PauseContainer(container *commands.Container) error {
@ -441,7 +358,7 @@ func (gui *Gui) PauseContainer(container *commands.Container) error {
}
func (gui *Gui) handleContainerPause(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
container, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
@ -450,7 +367,7 @@ func (gui *Gui) handleContainerPause(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
container, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
@ -467,7 +384,7 @@ func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
container, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
@ -482,7 +399,7 @@ func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleContainerAttach(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
container, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
@ -508,7 +425,7 @@ func (gui *Gui) handlePruneContainers() error {
}
func (gui *Gui) handleContainerViewLogs(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
container, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
@ -519,7 +436,7 @@ func (gui *Gui) handleContainerViewLogs(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleContainersExecShell(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
container, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
@ -540,7 +457,7 @@ func (gui *Gui) containerExecShell(container *commands.Container) error {
}
func (gui *Gui) handleContainersCustomCommand(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
container, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
@ -557,8 +474,10 @@ func (gui *Gui) handleContainersCustomCommand(g *gocui.Gui, v *gocui.View) error
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 _, container := range gui.DockerCommand.Containers {
_ = container.Stop()
for _, container := range gui.Panels.Containers.List.GetAllItems() {
if err := container.Stop(); err != nil {
gui.Log.Error(err)
}
}
return nil
@ -569,8 +488,10 @@ func (gui *Gui) handleStopContainers() error {
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 _, container := range gui.DockerCommand.Containers {
_ = container.Remove(types.ContainerRemoveOptions{Force: true})
for _, container := range gui.Panels.Containers.List.GetAllItems() {
if err := container.Remove(dockerTypes.ContainerRemoveOptions{Force: true}); err != nil {
gui.Log.Error(err)
}
}
return nil
@ -602,7 +523,7 @@ func (gui *Gui) handleContainersBulkCommand(g *gocui.Gui, v *gocui.View) error {
// Open first port in browser
func (gui *Gui) handleContainersOpenInBrowserCommand(g *gocui.Gui, v *gocui.View) error {
container, err := gui.getSelectedContainer()
container, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}

@ -4,67 +4,47 @@ 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"
)
type customCommandOption struct {
customCommand config.CustomCommand
description string
command string
name string
runCommand bool
attach bool
}
// GetDisplayStrings is a function.
func (r *customCommandOption) GetDisplayStrings(isFocused bool) []string {
return []string{r.name, utils.ColoredString(r.description, color.FgCyan)}
}
func (gui *Gui) createCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject, title string, waitingStatus string) error {
options := make([]*customCommandOption, len(customCommands)+1)
for i, command := range customCommands {
menuItems := lo.Map(customCommands, func(command config.CustomCommand, _ int) *types.MenuItem {
resolvedCommand := utils.ApplyTemplate(command.Command, commandObject)
options[i] = &customCommandOption{
customCommand: command,
description: utils.WithShortSha(resolvedCommand),
command: resolvedCommand,
runCommand: true,
attach: command.Attach,
name: command.Name,
}
}
options[len(options)-1] = &customCommandOption{
name: gui.Tr.Cancel,
runCommand: false,
}
onPress := func() error {
if command.InternalFunction != nil {
return command.InternalFunction()
}
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 command.Attach {
cmd := gui.OSCommand.ExecutableFromString(resolvedCommand)
return gui.runSubprocess(cmd)
}
if option.customCommand.InternalFunction != nil {
return option.customCommand.InternalFunction()
return gui.WithWaitingStatus(waitingStatus, func() error {
if err := gui.OSCommand.RunCommand(resolvedCommand); err != nil {
return gui.createErrorPanel(err.Error())
}
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)
return gui.runSubprocess(cmd)
return &types.MenuItem{
LabelColumns: []string{
command.Name,
utils.ColoredString(utils.WithShortSha(resolvedCommand), color.FgCyan),
},
OnPress: onPress,
}
})
return gui.WithWaitingStatus(waitingStatus, func() error {
if err := gui.OSCommand.RunCommand(option.command); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
}
return gui.createMenu(title, options, len(options), handleMenuPress)
return gui.Menu(CreateMenuOptions{
Title: title,
Items: menuItems,
})
}
func (gui *Gui) createCustomCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject) error {

@ -0,0 +1,80 @@
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)
}

@ -0,0 +1,146 @@
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()
}

@ -2,52 +2,30 @@ package gui
import (
"context"
"os"
"strings"
"sync"
"time"
"github.com/docker/docker/api/types"
dockerTypes "github.com/docker/docker/api/types"
"github.com/go-errors/errors"
throttle "github.com/boz/go-throttle"
"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 {
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{
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
@ -57,16 +35,10 @@ type Gui struct {
State guiState
Config *config.AppConfig
Tr *i18n.TranslationSet
Errors SentinelErrors
statusManager *statusManager
waitForIntro sync.WaitGroup
T *tasks.TaskManager
taskManager *tasks.TaskManager
ErrorChan chan error
CyclableViews []string
Views Views
// returns true if our views have been created and assigned to gui.Views.
// Views are setup only once, upon application start.
ViewsSetup bool
// 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
@ -74,30 +46,22 @@ type Gui struct {
PauseBackgroundThreads bool
Mutexes
}
type Mutexes struct {
SubprocessMutex sync.Mutex
ViewStackMutex sync.Mutex
}
type servicePanelState struct {
SelectedLine int
ContextIndex int // for specifying if you are looking at logs/stats/config/etc
Panels Panels
}
type containerPanelState struct {
SelectedLine int
ContextIndex int // for specifying if you are looking at logs/stats/config/etc
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]
Menu *panels.SideListPanel[*types.MenuItem]
}
type projectState struct {
ContextIndex int // for specifying if you are looking at credits/logs
}
type menuPanelState struct {
SelectedLine int
OnPress func(*gocui.Gui, *gocui.View) error
type Mutexes struct {
SubprocessMutex deadlock.Mutex
ViewStackMutex deadlock.Mutex
}
type mainPanelState struct {
@ -105,28 +69,11 @@ type mainPanelState struct {
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 {
Services *servicePanelState
Containers *containerPanelState
Menu *menuPanelState
Main *mainPanelState
Images *imagePanelState
Volumes *volumePanelState
Project *projectState
Main *mainPanelState
}
type guiState struct {
MenuItemCount int // can't store the actual list because it's of interface{} type
// the names of views in the current focus stack (last item is the current view)
ViewStack []string
Platform commands.Platform
@ -134,7 +81,24 @@ type guiState struct {
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
@ -154,39 +118,29 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand
initialState := guiState{
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: "",
},
Project: &projectState{ContextIndex: 0},
},
ViewStack: []string{},
}
cyclableViews := []string{"project", "containers", "images", "volumes"}
if dockerCommand.InDockerComposeProject {
cyclableViews = []string{"project", "services", "containers", "images", "volumes"}
ShowExitedContainers: true,
}
gui := &Gui{
Log: log,
DockerCommand: dockerCommand,
OSCommand: oSCommand,
// TODO: look into this warning
State: initialState,
Config: config,
Tr: tr,
statusManager: &statusManager{},
T: tasks.NewTaskManager(log, tr),
taskManager: tasks.NewTaskManager(log, tr),
ErrorChan: errorChan,
CyclableViews: cyclableViews,
}
gui.GenerateSentinelErrors()
deadlock.Opts.Disable = !gui.Config.Debug
deadlock.Opts.DeadlockTimeout = 10 * time.Second
return gui, nil
}
@ -195,7 +149,7 @@ func (gui *Gui) renderGlobalOptions() error {
return gui.renderOptionsMap(map[string]string{
"PgUp/PgDn": gui.Tr.Scroll,
"← → ↑ ↓": gui.Tr.Navigate,
"esc/q": gui.Tr.Close,
"q": gui.Tr.Quit,
"b": gui.Tr.ViewBulkCommands,
"x": gui.Tr.Menu,
})
@ -219,7 +173,7 @@ func (gui *Gui) goEvery(interval time.Duration, function func() error) {
// 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.T.Close()
defer gui.taskManager.Close()
g, err := gocui.NewGui(gocui.OutputTrue, OverlappingEdges, gocui.NORMAL, false, map[rune]string{})
if err != nil {
@ -234,31 +188,19 @@ func (gui *Gui) Run() error {
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
}
gui.waitForIntro.Add(1)
throttledRefresh := throttle.ThrottleFunc(time.Millisecond*50, true, gui.refresh)
defer throttledRefresh.Stop()
ctx, finish := context.WithCancel(context.Background())
defer finish()
go gui.listenForEvents(ctx, throttledRefresh.Trigger)
go gui.DockerCommand.MonitorContainerStats(ctx)
go func() {
gui.waitForIntro.Wait()
throttledRefresh.Trigger()
gui.goEvery(time.Millisecond*30, gui.reRenderMain)
gui.goEvery(time.Millisecond*1000, gui.DockerCommand.UpdateContainerDetails)
gui.goEvery(time.Millisecond*1000, gui.checkForContextChange)
gui.goEvery(time.Millisecond*1000, gui.rerenderContainersAndServices)
}()
go func() {
for err := range gui.ErrorChan {
if err == nil {
@ -275,10 +217,48 @@ func (gui *Gui) Run() 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
@ -286,26 +266,39 @@ func (gui *Gui) Run() error {
return err
}
func (gui *Gui) rerenderContainersAndServices() error {
// we need to regularly re-render these because their stats will be changed in the background
gui.renderContainersAndServices()
return nil
func (gui *Gui) setPanels() {
gui.Panels = Panels{
Projects: gui.getProjectPanel(),
Services: gui.getServicesPanel(),
Containers: gui.getContainersPanel(),
Images: gui.getImagesPanel(),
Volumes: gui.getVolumesPanel(),
Menu: gui.getMenuPanel(),
}
}
func (gui *Gui) updateContainerDetails() error {
return gui.DockerCommand.UpdateContainerDetails(gui.Panels.Containers.List.GetAllItems())
}
func (gui *Gui) refresh() {
go gui.refreshProject()
go func() {
if err := gui.refreshProject(); err != nil {
gui.Log.Error(err)
}
}()
go func() {
if err := gui.refreshContainersAndServices(); err != nil {
gui.Log.Error(err)
}
}()
go func() {
if err := gui.refreshVolumes(); err != nil {
if err := gui.reloadVolumes(); err != nil {
gui.Log.Error(err)
}
}()
go func() {
if err := gui.refreshImages(); err != nil {
if err := gui.reloadImages(); err != nil {
gui.Log.Error(err)
}
}()
@ -324,7 +317,7 @@ func (gui *Gui) listenForEvents(ctx context.Context, refresh func()) {
outer:
for {
messageChan, errChan := gui.DockerCommand.Client.Events(context.Background(), types.EventsOptions{})
messageChan, errChan := gui.DockerCommand.Client.Events(context.Background(), dockerTypes.EventsOptions{})
if errorCount > 0 {
select {
@ -371,7 +364,7 @@ func (gui *Gui) checkForContextChange() error {
}
func (gui *Gui) reRenderMain() error {
mainView := gui.getMainView()
mainView := gui.Views.Main
if mainView == nil {
return nil
}
@ -392,6 +385,16 @@ func (gui *Gui) quit(g *gocui.Gui, v *gocui.View) error {
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
@ -427,7 +430,7 @@ func (gui *Gui) handleCustomCommand(g *gocui.Gui, v *gocui.View) error {
})
}
func (gui *Gui) shouldRefresh(key string) bool {
func (gui *Gui) ShouldRefresh(key string) bool {
if gui.State.Panels.Main.ObjectKey == key {
return false
}
@ -442,3 +445,47 @@ func (gui *Gui) initiallyFocusedViewName() string {
}
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)
}
}
}
}
}
// 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.OutputTrue, false, gocui.NORMAL, true, map[rune]string{})
if err != nil {
panic(err)
}
gui.g = g
defer g.Close()
if err := gui.createAllViews(); err != nil {
panic(err)
}
gui.setPanels()
}

@ -5,258 +5,178 @@ import (
"strings"
"time"
"github.com/docker/docker/api/types"
dockerTypes "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"
)
// list panel functions
func (gui *Gui) getImagesPanel() *panels.SideListPanel[*commands.Image] {
noneLabel := "<none>"
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
}
func (gui *Gui) getImageContexts() []string {
return []string{"config"}
}
if a.Name != noneLabel && b.Name == noneLabel {
return true
}
func (gui *Gui) getImageContextTitles() []string {
return []string{gui.Tr.ConfigTitle}
}
if a.Name != b.Name {
return a.Name < b.Name
}
func (gui *Gui) getSelectedImage() (*commands.Image, error) {
selectedLine := gui.State.Panels.Images.SelectedLine
if selectedLine == -1 {
return &commands.Image{}, gui.Errors.ErrNoImages
}
if a.Tag != b.Tag {
return a.Tag < b.Tag
}
return gui.DockerCommand.Images[selectedLine], nil
return a.ID < b.ID
},
GetTableCells: presentation.GetImageDisplayStrings,
}
}
func (gui *Gui) handleImagesClick(g *gocui.Gui, v *gocui.View) error {
itemCount := len(gui.DockerCommand.Images)
handleSelect := gui.handleImageSelect
selectedLine := &gui.State.Panels.Images.SelectedLine
return gui.handleClick(v, itemCount, selectedLine, handleSelect)
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) handleImageSelect(g *gocui.Gui, v *gocui.View) error {
Image, err := gui.getSelectedImage()
if err != nil {
if err != gui.Errors.ErrNoImages {
return err
}
return gui.renderString(g, "main", gui.Tr.NoImages)
}
gui.focusY(gui.State.Panels.Images.SelectedLine, len(gui.DockerCommand.Images), v)
key := "images-" + Image.ID + "-" + gui.getImageContexts()[gui.State.Panels.Images.ContextIndex]
if !gui.shouldRefresh(key) {
return nil
}
mainView := gui.getMainView()
mainView.Tabs = gui.getImageContextTitles()
mainView.TabIndex = gui.State.Panels.Images.ContextIndex
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"
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")
history, err := image.RenderHistory()
if err != nil {
gui.Log.Error(err)
}
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 // don't care what your config is this page is ugly without wrapping
output += "\n\n" + history
_ = gui.renderString(gui.g, "main", output)
})
return 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
}
func (gui *Gui) reloadImages() error {
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() == gui.Views.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
return gui.Panels.Images.RerenderList()
}
// TODO: leave this to DockerCommand
func (gui *Gui) refreshStateImages() error {
Images, err := gui.DockerCommand.RefreshImages()
images, err := gui.DockerCommand.RefreshImages()
if err != nil {
return err
}
gui.DockerCommand.Images = Images
gui.Panels.Images.SetItems(images)
return nil
}
func (gui *Gui) handleImagesNextLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() || gui.g.CurrentView() != v {
return nil
func (gui *Gui) FilterString(view *gocui.View) string {
if gui.State.Filter.panel != nil && gui.State.Filter.panel.GetView() != view {
return ""
}
panelState := gui.State.Panels.Images
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.Images), false)
return gui.handleImageSelect(gui.g, v)
return gui.State.Filter.needle
}
func (gui *Gui) handleImagesPrevLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() || gui.g.CurrentView() != v {
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--
func (gui *Gui) handleImagesRemoveMenu(g *gocui.Gui, v *gocui.View) error {
type removeImageOption struct {
description string
command string
configOptions dockerTypes.ImageRemoveOptions
}
_ = 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 {
Image, err := gui.getSelectedImage()
image, err := gui.Panels.Images.GetSelectedItem()
if err != nil {
return nil
}
shortSha := Image.ID[7:17]
shortSha := image.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: types.ImageRemoveOptions{PruneChildren: true, Force: false},
runCommand: true,
configOptions: dockerTypes.ImageRemoveOptions{PruneChildren: true, Force: false},
},
{
description: gui.Tr.RemoveWithoutPrune,
command: "docker image rm --no-prune " + shortSha,
configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: false},
runCommand: true,
configOptions: dockerTypes.ImageRemoveOptions{PruneChildren: false, Force: false},
},
{
description: gui.Tr.RemoveWithForce,
command: "docker image rm --force " + shortSha,
configOptions: types.ImageRemoveOptions{PruneChildren: true, Force: true},
runCommand: true,
configOptions: dockerTypes.ImageRemoveOptions{PruneChildren: true, Force: true},
},
{
description: gui.Tr.RemoveWithoutPruneWithForce,
command: "docker image rm --no-prune --force " + shortSha,
configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: true},
runCommand: true,
},
{
description: gui.Tr.Cancel,
runCommand: false,
configOptions: dockerTypes.ImageRemoveOptions{PruneChildren: false, Force: true},
},
}
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(cerr.Error())
}
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 := image.Remove(option.configOptions); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
}
return nil
},
}
})
return gui.createMenu("", options, len(options), handleMenuPress)
return gui.Menu(CreateMenuOptions{
Title: "",
Items: menuItems,
})
}
func (gui *Gui) handlePruneImages() error {
@ -266,13 +186,13 @@ func (gui *Gui) handlePruneImages() error {
if err != nil {
return gui.createErrorPanel(err.Error())
}
return gui.refreshImages()
return gui.reloadImages()
})
}, nil)
}
func (gui *Gui) handleImagesCustomCommand(g *gocui.Gui, v *gocui.View) error {
image, err := gui.getSelectedImage()
image, err := gui.Panels.Images.GetSelectedItem()
if err != nil {
return nil
}

@ -17,11 +17,6 @@ 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
@ -63,19 +58,19 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
bindings := []*Binding{
{
ViewName: "",
Key: 'q',
Key: gocui.KeyEsc,
Modifier: gocui.ModNone,
Handler: gui.quit,
Handler: wrappedHandler(gui.escape),
},
{
ViewName: "",
Key: gocui.KeyCtrlC,
Key: 'q',
Modifier: gocui.ModNone,
Handler: gui.quit,
},
{
ViewName: "",
Key: gocui.KeyEsc,
Key: gocui.KeyCtrlC,
Modifier: gocui.ModNone,
Handler: gui.quit,
},
@ -83,25 +78,25 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
ViewName: "",
Key: gocui.KeyPgup,
Modifier: gocui.ModNone,
Handler: gui.scrollUpMain,
Handler: wrappedHandler(gui.scrollUpMain),
},
{
ViewName: "",
Key: gocui.KeyPgdn,
Modifier: gocui.ModNone,
Handler: gui.scrollDownMain,
Handler: wrappedHandler(gui.scrollDownMain),
},
{
ViewName: "",
Key: gocui.KeyCtrlU,
Modifier: gocui.ModNone,
Handler: gui.scrollUpMain,
Handler: wrappedHandler(gui.scrollUpMain),
},
{
ViewName: "",
Key: gocui.KeyCtrlD,
Modifier: gocui.ModNone,
Handler: gui.scrollDownMain,
Handler: wrappedHandler(gui.scrollDownMain),
},
{
ViewName: "",
@ -149,48 +144,40 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
},
{
ViewName: "project",
Key: '[',
Modifier: gocui.ModNone,
Handler: gui.handleProjectPrevContext,
Description: gui.Tr.PreviousContext,
},
{
ViewName: "project",
Key: ']',
Key: 'm',
Modifier: gocui.ModNone,
Handler: gui.handleProjectNextContext,
Description: gui.Tr.NextContext,
Handler: gui.handleViewAllLogs,
Description: gui.Tr.ViewLogs,
},
{
ViewName: "project",
Key: gocui.MouseLeft,
ViewName: "menu",
Key: gocui.KeyEsc,
Modifier: gocui.ModNone,
Handler: gui.handleProjectClick,
Handler: wrappedHandler(gui.handleMenuClose),
},
{
ViewName: "project",
Key: 'm',
Modifier: gocui.ModNone,
Handler: gui.handleViewAllLogs,
Description: gui.Tr.ViewLogs,
ViewName: "menu",
Key: 'q',
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.handleMenuClose),
},
{
ViewName: "project",
Key: gocui.MouseLeft,
ViewName: "menu",
Key: ' ',
Modifier: gocui.ModNone,
Handler: gui.handleProjectSelect,
Handler: wrappedHandler(gui.handleMenuPress),
},
{
ViewName: "menu",
Key: gocui.KeyEsc,
Key: gocui.KeyEnter,
Modifier: gocui.ModNone,
Handler: gui.handleMenuClose,
Handler: wrappedHandler(gui.handleMenuPress),
},
{
ViewName: "menu",
Key: 'q',
Key: 'y',
Modifier: gocui.ModNone,
Handler: gui.handleMenuClose,
Handler: wrappedHandler(gui.handleMenuPress),
},
{
ViewName: "information",
@ -198,20 +185,6 @@ 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',
@ -359,20 +332,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleProjectDown,
Description: gui.Tr.DownProject,
},
{
ViewName: "services",
Key: '[',
Modifier: gocui.ModNone,
Handler: gui.handleServicesPrevContext,
Description: gui.Tr.PreviousContext,
},
{
ViewName: "services",
Key: ']',
Modifier: gocui.ModNone,
Handler: gui.handleServicesNextContext,
Description: gui.Tr.NextContext,
},
{
ViewName: "services",
Key: 'R',
@ -408,20 +367,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleServicesOpenInBrowserCommand,
Description: gui.Tr.OpenInBrowser,
},
{
ViewName: "images",
Key: '[',
Modifier: gocui.ModNone,
Handler: gui.handleImagesPrevContext,
Description: gui.Tr.PreviousContext,
},
{
ViewName: "images",
Key: ']',
Modifier: gocui.ModNone,
Handler: gui.handleImagesNextContext,
Description: gui.Tr.NextContext,
},
{
ViewName: "images",
Key: 'c',
@ -443,20 +388,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleImagesBulkCommand,
Description: gui.Tr.ViewBulkCommands,
},
{
ViewName: "volumes",
Key: '[',
Modifier: gocui.ModNone,
Handler: gui.handleVolumesPrevContext,
Description: gui.Tr.PreviousContext,
},
{
ViewName: "volumes",
Key: ']',
Modifier: gocui.ModNone,
Handler: gui.handleVolumesNextContext,
Description: gui.Tr.NextContext,
},
{
ViewName: "volumes",
Key: 'c',
@ -509,17 +440,29 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
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: gui.scrollDownMain,
Handler: wrappedHandler(gui.scrollDownMain),
},
{
ViewName: "",
Key: 'K',
Modifier: gocui.ModNone,
Handler: gui.scrollUpMain,
Handler: wrappedHandler(gui.scrollUpMain),
},
{
ViewName: "",
@ -547,51 +490,71 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
},
}
// TODO: add more views here
for _, viewName := range []string{"project", "services", "containers", "images", "volumes", "menu"} {
for _, panel := range gui.allSidePanels() {
bindings = append(bindings, []*Binding{
{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},
{ViewName: viewName, Key: gocui.KeyTab, Modifier: gocui.ModNone, Handler: gui.nextView},
{ViewName: viewName, Key: gocui.KeyBacktab, Modifier: gocui.ModNone, Handler: gui.previousView},
{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},
}...)
}
panelMap := map[string]struct {
onKeyUpPress func(*gocui.Gui, *gocui.View) error
onKeyDownPress func(*gocui.Gui, *gocui.View) error
onClick func(*gocui.Gui, *gocui.View) error
}{
"menu": {onKeyUpPress: gui.handleMenuPrevLine, onKeyDownPress: gui.handleMenuNextLine, onClick: gui.handleMenuClick},
"services": {onKeyUpPress: gui.handleServicesPrevLine, onKeyDownPress: gui.handleServicesNextLine, onClick: gui.handleServicesClick},
"containers": {onKeyUpPress: gui.handleContainersPrevLine, onKeyDownPress: gui.handleContainersNextLine, onClick: gui.handleContainersClick},
"images": {onKeyUpPress: gui.handleImagesPrevLine, onKeyDownPress: gui.handleImagesNextLine, onClick: gui.handleImagesClick},
"volumes": {onKeyUpPress: gui.handleVolumesPrevLine, onKeyDownPress: gui.handleVolumesNextLine, onClick: gui.handleVolumesClick},
"main": {onKeyUpPress: gui.scrollUpMain, onKeyDownPress: gui.scrollDownMain, onClick: gui.handleMainClick},
}
for viewName, functions := range panelMap {
setUpDownClickBindings := func(viewName string, onUp func() error, onDown func() error, onClick func() error) {
bindings = append(bindings, []*Binding{
{ViewName: viewName, Key: 'k', Modifier: gocui.ModNone, Handler: functions.onKeyUpPress},
{ViewName: viewName, Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, Handler: functions.onKeyUpPress},
{ViewName: viewName, Key: gocui.MouseWheelUp, Modifier: gocui.ModNone, Handler: functions.onKeyUpPress},
{ViewName: viewName, Key: 'j', Modifier: gocui.ModNone, Handler: functions.onKeyDownPress},
{ViewName: viewName, Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, Handler: functions.onKeyDownPress},
{ViewName: viewName, Key: gocui.MouseWheelDown, Modifier: gocui.ModNone, Handler: functions.onKeyDownPress},
{ViewName: viewName, Key: gocui.MouseLeft, Modifier: gocui.ModNone, Handler: functions.onClick},
{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)},
}...)
}
for _, viewName := range []string{"project", "services", "containers", "images", "volumes"} {
bindings = append(bindings, &Binding{
ViewName: viewName,
Key: gocui.KeyEnter,
Modifier: gocui.ModNone,
Handler: gui.handleEnterMain,
Description: gui.Tr.FocusMain,
})
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
@ -601,7 +564,7 @@ func (gui *Gui) keybindings(g *gocui.Gui) error {
bindings := gui.GetInitialKeybindings()
for _, binding := range bindings {
if err := g.SetKeybinding(binding.ViewName, nil, binding.Key, binding.Modifier, binding.Handler); err != nil {
if err := g.SetKeybinding(binding.ViewName, binding.Key, binding.Modifier, binding.Handler); err != nil {
return err
}
}

@ -59,14 +59,6 @@ func (gui *Gui) onFocus(v *gocui.View) {
// layout is called for every screen re-render e.g. when the screen is resized
func (gui *Gui) layout(g *gocui.Gui) error {
if !gui.ViewsSetup {
if err := gui.createAllViews(); err != nil {
return err
}
gui.ViewsSetup = true
}
g.Highlight = true
width, height := g.Size()
@ -109,25 +101,13 @@ func (gui *Gui) layout(g *gocui.Gui) error {
return view, err
}
for _, viewName := range gui.controlledBoundsViewNames() {
for _, viewName := range gui.autoPositionedViewNames() {
_, err := setViewFromDimensions(viewName, viewName)
if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
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
}
}
// here is a good place log some stuff
// if you download humanlog and do tail -f development.log | humanlog
// this will let you see these branches as prettified json
@ -135,26 +115,14 @@ func (gui *Gui) layout(g *gocui.Gui) error {
return gui.resizeCurrentPopupPanel(g)
}
type listViewState struct {
selectedLine int
lineCount int
}
func (gui *Gui) focusPointInView(view *gocui.View) {
if view == nil {
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 {
gui.focusY(state.selectedLine, state.lineCount, view)
currentPanel, ok := gui.currentListPanel()
if ok {
currentPanel.Refocus()
}
}

@ -6,16 +6,16 @@ import (
"github.com/jesseduffield/gocui"
)
func (gui *Gui) scrollUpMain(g *gocui.Gui, v *gocui.View) error {
mainView := gui.getMainView()
func (gui *Gui) scrollUpMain() error {
mainView := gui.Views.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(g *gocui.Gui, v *gocui.View) error {
mainView := gui.getMainView()
func (gui *Gui) scrollDownMain() error {
mainView := gui.Views.Main
mainView.Autoscroll = false
ox, oy := mainView.Origin()
@ -34,7 +34,7 @@ func (gui *Gui) scrollDownMain(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) scrollLeftMain(g *gocui.Gui, v *gocui.View) error {
mainView := gui.getMainView()
mainView := gui.Views.Main
ox, oy := mainView.Origin()
newOx := int(math.Max(0, float64(ox-gui.Config.UserConfig.Gui.ScrollHeight)))
@ -42,7 +42,7 @@ func (gui *Gui) scrollLeftMain(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) scrollRightMain(g *gocui.Gui, v *gocui.View) error {
mainView := gui.getMainView()
mainView := gui.Views.Main
ox, oy := mainView.Origin()
content := mainView.ViewBufferLines()
@ -62,50 +62,32 @@ func (gui *Gui) scrollRightMain(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) autoScrollMain(g *gocui.Gui, v *gocui.View) error {
gui.getMainView().Autoscroll = true
gui.Views.Main.Autoscroll = true
return nil
}
func (gui *Gui) jumpToTopMain(g *gocui.Gui, v *gocui.View) error {
gui.getMainView().Autoscroll = false
_ = gui.getMainView().SetOrigin(0, 0)
_ = gui.getMainView().SetCursor(0, 0)
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)
viewName := gui.currentViewName()
currentSidePanel, ok := gui.currentSidePanel()
mainView := gui.getMainView()
if viewName == "main" && mainView.ParentView != nil {
viewName = mainView.ParentView.Name()
}
switch viewName {
case "project":
gui.State.Panels.Project.ContextIndex = tabIndex
return gui.handleProjectSelect(gui.g, gui.getProjectView())
case "services":
gui.State.Panels.Services.ContextIndex = tabIndex
return gui.handleServiceSelect(gui.g, gui.getServicesView())
case "containers":
gui.State.Panels.Containers.ContextIndex = tabIndex
return gui.handleContainerSelect(gui.g, gui.getContainersView())
case "images":
gui.State.Panels.Images.ContextIndex = tabIndex
return gui.handleImageSelect(gui.g, gui.getImagesView())
case "volumes":
gui.State.Panels.Volumes.ContextIndex = tabIndex
return gui.handleVolumeSelect(gui.g, gui.getVolumesView())
if !ok {
return nil
}
return nil
currentSidePanel.SetMainTabIndex(tabIndex)
return currentSidePanel.HandleSelect()
}
func (gui *Gui) handleEnterMain(g *gocui.Gui, v *gocui.View) error {
mainView := gui.getMainView()
mainView := gui.Views.Main
mainView.ParentView = v
return gui.switchFocus(mainView)
@ -116,18 +98,16 @@ func (gui *Gui) handleExitMain(g *gocui.Gui, v *gocui.View) error {
return gui.returnFocus()
}
func (gui *Gui) handleMainClick(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleMainClick() error {
if gui.popupPanelFocused() {
return nil
}
currentView := gui.g.CurrentView()
if currentView != nil && currentView.Name() == "main" {
currentView = nil
} else {
v.ParentView = currentView
if currentView.Name() != "main" {
gui.Views.Main.ParentView = currentView
}
return gui.switchFocus(v)
return gui.switchFocus(gui.Views.Main)
}

@ -1,112 +1,133 @@
package gui
import (
"fmt"
"github.com/jesseduffield/gocui"
"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/utils"
)
// list panel functions
type CreateMenuOptions struct {
Title string
Items []*types.MenuItem
HideCancel bool
}
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) onMenuPress(menuItem *types.MenuItem) error {
if err := gui.handleMenuClose(); err != nil {
return err
}
if menuItem.OnPress != nil {
return menuItem.OnPress()
}
func (gui *Gui) handleMenuSelect(g *gocui.Gui, v *gocui.View) error {
gui.focusY(gui.State.Panels.Menu.SelectedLine, gui.State.MenuItemCount, v)
return nil
}
func (gui *Gui) handleMenuNextLine(g *gocui.Gui, v *gocui.View) error {
panelState := gui.State.Panels.Menu
gui.changeSelectedLine(&panelState.SelectedLine, v.LinesHeight(), false)
func (gui *Gui) handleMenuPress() error {
selectedMenuItem, err := gui.Panels.Menu.GetSelectedItem()
if err != nil {
return nil
}
return gui.handleMenuSelect(g, v)
return gui.onMenuPress(selectedMenuItem)
}
func (gui *Gui) handleMenuClick(g *gocui.Gui, v *gocui.View) error {
itemCount := gui.State.MenuItemCount
handleSelect := gui.handleMenuSelect
selectedLine := &gui.State.Panels.Menu.SelectedLine
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
},
})
}
if err := gui.handleClick(v, itemCount, selectedLine, handleSelect); err != nil {
return err
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))
}
return gui.State.Panels.Menu.OnPress(g, v)
}
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)
func (gui *Gui) handleMenuPrevLine(g *gocui.Gui, v *gocui.View) error {
panelState := gui.State.Panels.Menu
gui.changeSelectedLine(&panelState.SelectedLine, v.LinesHeight(), true)
if err := gui.Panels.Menu.RerenderList(); err != nil {
return err
}
gui.Views.Menu.Title = opts.Title
gui.Views.Menu.Visible = true
return gui.handleMenuSelect(g, v)
return gui.switchFocus(gui.Views.Menu)
}
// specific functions
func (gui *Gui) renderMenuOptions() error {
optionsMap := map[string]string{
"esc/q": gui.Tr.Close,
"esc": gui.Tr.Close,
"↑ ↓": gui.Tr.Navigate,
"enter": gui.Tr.Execute,
}
return gui.renderOptionsMap(optionsMap)
}
func (gui *Gui) handleMenuClose(g *gocui.Gui, v *gocui.View) error {
for _, key := range []gocui.Key{gocui.KeySpace, gocui.KeyEnter, 'y'} {
if err := g.DeleteKeybinding("menu", key, gocui.ModNone); err != nil {
return err
}
}
func (gui *Gui) handleMenuClose() error {
gui.Views.Menu.Visible = false
return gui.returnFocus()
}
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
}
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(gui.g, false, list)
_, _ = gui.g.SetView("menu", x0, y0, x1, y1, 0)
menuView := gui.Views.Menu
menuView.Title = title
menuView.FgColor = gocui.ColorDefault
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
menuView.Visible = false
err := gui.returnFocus()
if err != nil {
// 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 {
return err
}
if err := handlePress(selectedLine); err != nil {
return err
}
return nil
}
gui.State.Panels.Menu.OnPress = wrappedHandlePress
for _, key := range []gocui.Key{gocui.KeySpace, gocui.KeyEnter, 'y'} {
_ = gui.g.DeleteKeybinding("menu", key, gocui.ModNone)
if err := gui.g.SetKeybinding("menu", nil, key, gocui.ModNone, wrappedHandlePress); err != nil {
return err
}
// 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)
}
gui.g.Update(func(g *gocui.Gui) error {
menuView.Visible = true
return gui.switchFocus(menuView)
})
return nil
return gui.returnFocus()
}

@ -1,9 +1,10 @@
package gui
import (
"github.com/go-errors/errors"
"github.com/samber/lo"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/gui/types"
)
func (gui *Gui) getBindings(v *gocui.View) []*Binding {
@ -40,10 +41,6 @@ func (gui *Gui) getBindings(v *gocui.View) []*Binding {
}
}
// } else if v.ParentView != nil && binding.ViewName == v.ParentView.Name() {
// // only add this if we don't have our own matching binding
// bindingsPanel = append(bindingsPanel, binding)
// append dummy element to have a separator between
// panel and global keybindings
bindingsPanel = append(bindingsPanel, &Binding{})
@ -51,22 +48,26 @@ func (gui *Gui) getBindings(v *gocui.View) []*Binding {
}
func (gui *Gui) handleCreateOptionsMenu(g *gocui.Gui, v *gocui.View) error {
if v.Name() == "menu" || v.Name() == "confirmation" {
if gui.isPopupPanel(v.Name()) {
return nil
}
bindings := gui.getBindings(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
}
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")
return binding.Handler(g, v)
},
}
})
return bindings[index].Handler(g, v)
}
return gui.createMenu(gui.Tr.MenuTitle, bindings, len(bindings), handleMenuPress)
return gui.Menu(CreateMenuOptions{
Title: gui.Tr.MenuTitle,
Items: menuItems,
HideCancel: true,
})
}

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

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

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

@ -0,0 +1,183 @@
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)
}
}

@ -0,0 +1,42 @@
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)
}

@ -0,0 +1,271 @@
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
// 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()
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()
}

@ -0,0 +1,147 @@
package presentation
import (
"encoding/json"
"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))
originalJSON, err := json.MarshalIndent(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,
string(originalJSON),
)
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())
}
}
}

@ -0,0 +1,145 @@
package presentation
import (
"fmt"
"sort"
"strconv"
"strings"
dockerTypes "github.com/docker/docker/api/types"
"github.com/fatih/color"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
)
func GetContainerDisplayStrings(container *commands.Container) []string {
return []string{
getContainerDisplayStatus(container),
getContainerDisplaySubstatus(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 dockerTypes.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(c *commands.Container) string {
return utils.ColoredString(c.Container.State, 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(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(c)
default:
return ""
}
}
func getHealthStatus(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 ""
}
healthStatus := c.Details.State.Health.Status
if healthStatusColor, ok := healthStatusColorMap[healthStatus]; 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
}
}

@ -0,0 +1,14 @@
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)),
}
}

@ -0,0 +1,7 @@
package presentation
import "github.com/jesseduffield/lazydocker/pkg/gui/types"
func GetMenuItemDisplayStrings(menuItem *types.MenuItem) []string {
return menuItem.LabelColumns
}

@ -0,0 +1,7 @@
package presentation
import "github.com/jesseduffield/lazydocker/pkg/commands"
func GetProjectDisplayStrings(project *commands.Project) []string {
return []string{project.Name}
}

@ -0,0 +1,30 @@
package presentation
import (
"github.com/fatih/color"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/utils"
)
func GetServiceDisplayStrings(service *commands.Service) []string {
if service.Container == nil {
return []string{
utils.ColoredString("none", color.FgBlue),
"",
service.Name,
"",
"",
"",
}
}
container := service.Container
return []string{
getContainerDisplayStatus(container),
getContainerDisplaySubstatus(container),
service.Name,
getDisplayCPUPerc(container),
utils.ColoredString(displayPorts(container), color.FgYellow),
utils.ColoredString(displayContainerImage(container), color.FgMagenta),
}
}

@ -0,0 +1,7 @@
package presentation
import "github.com/jesseduffield/lazydocker/pkg/commands"
func GetVolumeDisplayStrings(volume *commands.Volume) []string {
return []string{volume.Volume.Driver, volume.Name}
}

@ -2,48 +2,85 @@ package gui
import (
"bytes"
"fmt"
"context"
"path"
"strings"
"github.com/fatih/color"
"github.com/go-errors/errors"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/gui/panels"
"github.com/jesseduffield/lazydocker/pkg/gui/presentation"
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/jesseduffield/yaml"
)
func (gui *Gui) getProjectContexts() []string {
if gui.DockerCommand.InDockerComposeProject {
return []string{"logs", "config", "credits"}
}
return []string{"credits"}
}
func (gui *Gui) getProjectContextTitles() []string {
if gui.DockerCommand.InDockerComposeProject {
return []string{gui.Tr.LogsTitle, gui.Tr.DockerComposeConfigTitle, gui.Tr.CreditsTitle}
// Although at the moment we'll only have one project, in future we could have
// a list of projects in the project panel.
func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] {
return &panels.SideListPanel[*commands.Project]{
ContextState: &panels.ContextState[*commands.Project]{
GetMainTabs: func() []panels.MainTab[*commands.Project] {
if gui.DockerCommand.InDockerComposeProject {
return []panels.MainTab[*commands.Project]{
{
Key: "logs",
Title: gui.Tr.LogsTitle,
Render: gui.renderAllLogs,
},
{
Key: "config",
Title: gui.Tr.DockerComposeConfigTitle,
Render: gui.renderDockerComposeConfig,
},
{
Key: "credits",
Title: gui.Tr.CreditsTitle,
Render: gui.renderCredits,
},
}
}
return []panels.MainTab[*commands.Project]{
{
Key: "credits",
Title: gui.Tr.CreditsTitle,
Render: gui.renderCredits,
},
}
},
GetItemContextCacheKey: func(project *commands.Project) string {
return "projects-" + project.Name
},
},
ListPanel: panels.ListPanel[*commands.Project]{
List: panels.NewFilteredList[*commands.Project](),
View: gui.Views.Project,
},
NoItemsMessage: "",
Gui: gui.intoInterface(),
Sort: func(a *commands.Project, b *commands.Project) bool {
return false
},
GetTableCells: presentation.GetProjectDisplayStrings,
// It doesn't make sense to filter a list of only one item.
DisableFilter: true,
}
return []string{gui.Tr.CreditsTitle}
}
func (gui *Gui) refreshProject() {
v := gui.getProjectView()
projectName := gui.getProjectName()
gui.g.Update(func(*gocui.Gui) error {
v.Clear()
fmt.Fprint(v, projectName)
return nil
})
func (gui *Gui) refreshProject() error {
gui.Panels.Projects.SetItems([]*commands.Project{{Name: gui.getProjectName()}})
return gui.Panels.Projects.RerenderList()
}
func (gui *Gui) getProjectName() string {
projectName := path.Base(gui.Config.ProjectDir)
if gui.DockerCommand.InDockerComposeProject {
for _, service := range gui.DockerCommand.Services {
for _, service := range gui.Panels.Services.List.GetAllItems() {
container := service.Container
if container != nil && container.DetailsLoaded() {
return container.Details.Config.Labels["com.docker.compose.project"]
@ -54,120 +91,61 @@ func (gui *Gui) getProjectName() string {
return projectName
}
func (gui *Gui) handleProjectClick(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() {
return nil
}
if _, err := gui.g.SetCurrentView(v.Name()); err != nil {
return err
}
return gui.handleProjectSelect(g, v)
func (gui *Gui) renderCredits(_project *commands.Project) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string { return gui.creditsStr() })
}
func (gui *Gui) handleProjectSelect(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() {
return nil
}
key := gui.getProjectContexts()[gui.State.Panels.Project.ContextIndex]
if !gui.shouldRefresh(key) {
return nil
}
gui.clearMainView()
mainView := gui.getMainView()
mainView.Tabs = gui.getProjectContextTitles()
mainView.TabIndex = gui.State.Panels.Project.ContextIndex
switch gui.getProjectContexts()[gui.State.Panels.Project.ContextIndex] {
case "credits":
if err := gui.renderCredits(); err != nil {
return err
}
case "logs":
if err := gui.renderAllLogs(); err != nil {
return err
}
case "config":
if err := gui.renderDockerComposeConfig(); err != nil {
return err
}
default:
return errors.New("Unknown context for status panel")
}
return nil
func (gui *Gui) creditsStr() string {
var configBuf bytes.Buffer
_ = yaml.NewEncoder(&configBuf, yaml.IncludeOmitted).Encode(gui.Config.UserConfig)
return strings.Join(
[]string{
lazydockerTitle(),
"Copyright (c) 2019 Jesse Duffield",
"Keybindings: https://github.com/jesseduffield/lazydocker/blob/master/docs/keybindings",
"Config Options: https://github.com/jesseduffield/lazydocker/blob/master/docs/Config.md",
"Raise an Issue: https://github.com/jesseduffield/lazydocker/issues",
utils.ColoredString("Buy Jesse a coffee: https://github.com/sponsors/jesseduffield", color.FgMagenta), // caffeine ain't free
"Here's your lazydocker config when merged in with the defaults (you can open your config by pressing 'o'):",
configBuf.String(),
}, "\n\n")
}
func (gui *Gui) renderCredits() error {
return gui.T.NewTask(func(stop chan struct{}) {
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
var configBuf bytes.Buffer
_ = yaml.NewEncoder(&configBuf, yaml.IncludeOmitted).Encode(gui.Config.UserConfig)
dashboardString := strings.Join(
[]string{
lazydockerTitle(),
"Copyright (c) 2019 Jesse Duffield",
"Keybindings: https://github.com/jesseduffield/lazydocker/blob/master/docs/keybindings",
"Config Options: https://github.com/jesseduffield/lazydocker/blob/master/docs/Config.md",
"Raise an Issue: https://github.com/jesseduffield/lazydocker/issues",
utils.ColoredString("Buy Jesse a coffee: https://github.com/sponsors/jesseduffield", color.FgMagenta), // caffeine ain't free
"Here's your lazydocker config when merged in with the defaults (you can open your config by pressing 'o'):",
configBuf.String(),
}, "\n\n")
_ = gui.renderString(gui.g, "main", dashboardString)
func (gui *Gui) renderAllLogs(_project *commands.Project) tasks.TaskFunc {
return gui.NewTask(TaskOpts{
Autoscroll: true,
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
Func: func(ctx context.Context) {
gui.clearMainView()
cmd := gui.OSCommand.RunCustomCommand(
utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.AllLogs,
gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
),
)
cmd.Stdout = gui.Views.Main
cmd.Stderr = gui.Views.Main
gui.OSCommand.PrepareForChildren(cmd)
_ = cmd.Start()
go func() {
<-ctx.Done()
if err := gui.OSCommand.Kill(cmd); err != nil {
gui.Log.Error(err)
}
}()
_ = cmd.Wait()
},
})
}
func (gui *Gui) renderAllLogs() error {
return gui.T.NewTask(func(stop chan struct{}) {
mainView := gui.getMainView()
mainView.Autoscroll = true
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
gui.clearMainView()
cmd := gui.OSCommand.RunCustomCommand(
utils.ApplyTemplate(
gui.Config.UserConfig.CommandTemplates.AllLogs,
gui.DockerCommand.NewCommandObject(commands.CommandObject{}),
),
)
cmd.Stdout = mainView
cmd.Stderr = mainView
gui.OSCommand.PrepareForChildren(cmd)
_ = cmd.Start()
go func() {
<-stop
if err := gui.OSCommand.Kill(cmd); err != nil {
gui.Log.Error(err)
}
}()
_ = cmd.Wait()
})
}
func (gui *Gui) renderDockerComposeConfig() error {
return gui.T.NewTask(func(stop chan struct{}) {
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
config := gui.DockerCommand.DockerComposeConfig()
_ = gui.renderString(gui.g, "main", config)
})
func (gui *Gui) renderDockerComposeConfig(_project *commands.Project) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string { return gui.DockerCommand.DockerComposeConfig() })
}
func (gui *Gui) handleOpenConfig(g *gocui.Gui, v *gocui.View) error {
@ -191,32 +169,6 @@ func lazydockerTitle() string {
`
}
func (gui *Gui) handleProjectNextContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getProjectContexts()
if gui.State.Panels.Project.ContextIndex >= len(contexts)-1 {
gui.State.Panels.Project.ContextIndex = 0
} else {
gui.State.Panels.Project.ContextIndex++
}
_ = gui.handleProjectSelect(gui.g, v)
return nil
}
func (gui *Gui) handleProjectPrevContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getProjectContexts()
if gui.State.Panels.Project.ContextIndex <= 0 {
gui.State.Panels.Project.ContextIndex = len(contexts) - 1
} else {
gui.State.Panels.Project.ContextIndex--
}
_ = gui.handleProjectSelect(gui.g, v)
return nil
}
// handleViewAllLogs switches to a subprocess viewing all the logs from docker-compose
func (gui *Gui) handleViewAllLogs(g *gocui.Gui, v *gocui.View) error {
c, err := gui.DockerCommand.ViewAllLogs()

@ -1,201 +1,147 @@
package gui
import (
"context"
"fmt"
"time"
"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"
)
// list panel functions
func (gui *Gui) getServicesPanel() *panels.SideListPanel[*commands.Service] {
return &panels.SideListPanel[*commands.Service]{
ContextState: &panels.ContextState[*commands.Service]{
GetMainTabs: func() []panels.MainTab[*commands.Service] {
return []panels.MainTab[*commands.Service]{
{
Key: "logs",
Title: gui.Tr.LogsTitle,
Render: gui.renderServiceLogs,
},
{
Key: "stats",
Title: gui.Tr.StatsTitle,
Render: gui.renderServiceStats,
},
{
Key: "container-env",
Title: gui.Tr.ContainerEnvTitle,
Render: gui.renderServiceContainerEnv,
},
{
Key: "container-config",
Title: gui.Tr.ContainerConfigTitle,
Render: gui.renderServiceContainerConfig,
},
{
Key: "top",
Title: gui.Tr.TopTitle,
Render: gui.renderServiceTop,
},
}
},
GetItemContextCacheKey: func(service *commands.Service) string {
if service.Container == nil {
return "services-" + service.ID
}
return "services-" + service.ID + "-" + service.Container.ID + "-" + service.Container.Container.State
},
},
ListPanel: panels.ListPanel[*commands.Service]{
List: panels.NewFilteredList[*commands.Service](),
View: gui.Views.Services,
},
NoItemsMessage: gui.Tr.NoServices,
Gui: gui.intoInterface(),
// sort services first by whether they have a linked container, and second by alphabetical order
Sort: func(a *commands.Service, b *commands.Service) bool {
if a.Container != nil && b.Container == nil {
return true
}
func (gui *Gui) getServiceContexts() []string {
return []string{"logs", "stats", "container-env", "container-config", "top"}
}
if a.Container == nil && b.Container != nil {
return false
}
func (gui *Gui) getServiceContextTitles() []string {
return []string{
gui.Tr.LogsTitle,
gui.Tr.StatsTitle,
gui.Tr.ContainerEnvTitle,
gui.Tr.ContainerConfigTitle,
gui.Tr.TopTitle,
return a.Name < b.Name
},
GetTableCells: presentation.GetServiceDisplayStrings,
Hide: func() bool {
return !gui.DockerCommand.InDockerComposeProject
},
}
}
func (gui *Gui) getSelectedService() (*commands.Service, error) {
selectedLine := gui.State.Panels.Services.SelectedLine
if selectedLine == -1 {
return &commands.Service{}, errors.New("no service selected")
func (gui *Gui) renderServiceContainerConfig(service *commands.Service) tasks.TaskFunc {
if service.Container == nil {
return gui.NewSimpleRenderStringTask(func() string { return gui.Tr.NoContainer })
}
return gui.DockerCommand.Services[selectedLine], nil
}
func (gui *Gui) handleServicesClick(g *gocui.Gui, v *gocui.View) error {
itemCount := len(gui.DockerCommand.Services)
handleSelect := gui.handleServiceSelect
selectedLine := &gui.State.Panels.Services.SelectedLine
return gui.handleClick(v, itemCount, selectedLine, handleSelect)
return gui.renderContainerConfig(service.Container)
}
func (gui *Gui) handleServiceSelect(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
if err != nil {
return nil
}
containerID := ""
if service.Container != nil {
containerID = service.Container.ID
}
gui.focusY(gui.State.Panels.Services.SelectedLine, len(gui.DockerCommand.Services), v)
key := "services-" + service.ID + "-" + containerID + "-" + gui.getServiceContexts()[gui.State.Panels.Services.ContextIndex]
if !gui.shouldRefresh(key) {
return nil
}
mainView := gui.getMainView()
mainView.Tabs = gui.getServiceContextTitles()
mainView.TabIndex = gui.State.Panels.Services.ContextIndex
switch gui.getServiceContexts()[gui.State.Panels.Services.ContextIndex] {
case "logs":
if err := gui.renderServiceLogs(service); err != nil {
return err
}
case "stats":
if err := gui.renderServiceStats(service); err != nil {
return err
}
case "container-env":
if service.Container == nil {
return gui.renderString(gui.g, "main", gui.Tr.NoContainer)
}
if err := gui.renderContainerEnv(service.Container); err != nil {
return err
}
case "container-config":
if service.Container == nil {
return gui.renderString(gui.g, "main", gui.Tr.NoContainer)
}
if err := gui.renderContainerConfig(service.Container); err != nil {
return err
}
case "top":
if err := gui.renderServiceTop(service); err != nil {
return err
}
default:
return errors.New("Unknown context for services panel")
func (gui *Gui) renderServiceContainerEnv(service *commands.Service) tasks.TaskFunc {
if service.Container == nil {
return gui.NewSimpleRenderStringTask(func() string { return gui.Tr.NoContainer })
}
return nil
return gui.renderContainerEnv(service.Container)
}
func (gui *Gui) renderServiceStats(service *commands.Service) error {
func (gui *Gui) renderServiceStats(service *commands.Service) tasks.TaskFunc {
if service.Container == nil {
return nil
return gui.NewSimpleRenderStringTask(func() string { return gui.Tr.NoContainer })
}
return gui.renderContainerStats(service.Container)
}
func (gui *Gui) renderServiceTop(service *commands.Service) error {
mainView := gui.getMainView()
mainView.Autoscroll = false
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
return gui.T.NewTickerTask(time.Second, func(stop chan struct{}) { gui.clearMainView() }, func(stop, notifyStopped chan struct{}) {
contents, err := service.RenderTop()
if err != nil {
gui.reRenderStringMain(err.Error())
}
func (gui *Gui) renderServiceTop(service *commands.Service) tasks.TaskFunc {
return gui.NewTickerTask(TickerTaskOpts{
Func: func(ctx context.Context, notifyStopped chan struct{}) {
contents, err := service.RenderTop(ctx)
if err != nil {
gui.RenderStringMain(err.Error())
}
gui.reRenderStringMain(contents)
gui.reRenderStringMain(contents)
},
Duration: time.Second,
Before: func(ctx context.Context) { gui.clearMainView() },
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
Autoscroll: false,
})
}
func (gui *Gui) renderServiceLogs(service *commands.Service) error {
func (gui *Gui) renderServiceLogs(service *commands.Service) tasks.TaskFunc {
if service.Container == nil {
return gui.T.NewTask(func(stop chan struct{}) {
gui.clearMainView()
})
return gui.NewSimpleRenderStringTask(func() string { return gui.Tr.NoContainerForService })
}
return gui.renderContainerLogsToMain(service.Container)
}
func (gui *Gui) handleServicesNextLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() || gui.g.CurrentView() != v {
return nil
}
panelState := gui.State.Panels.Services
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.Services), false)
return gui.handleServiceSelect(gui.g, v)
}
func (gui *Gui) handleServicesPrevLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() || gui.g.CurrentView() != v {
return nil
}
panelState := gui.State.Panels.Services
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.Services), true)
return gui.handleServiceSelect(gui.g, v)
}
func (gui *Gui) handleServicesNextContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getServiceContexts()
if gui.State.Panels.Services.ContextIndex >= len(contexts)-1 {
gui.State.Panels.Services.ContextIndex = 0
} else {
gui.State.Panels.Services.ContextIndex++
}
_ = gui.handleServiceSelect(gui.g, v)
return nil
}
func (gui *Gui) handleServicesPrevContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getServiceContexts()
if gui.State.Panels.Services.ContextIndex <= 0 {
gui.State.Panels.Services.ContextIndex = len(contexts) - 1
} else {
gui.State.Panels.Services.ContextIndex--
}
_ = gui.handleServiceSelect(gui.g, v)
return nil
}
type commandOption struct {
description string
command string
f func() error
onPress func() error
}
// GetDisplayStrings is a function.
func (r *commandOption) GetDisplayStrings(isFocused bool) []string {
func (r *commandOption) getDisplayStrings() []string {
return []string{r.description, color.New(color.FgCyan).Sprint(r.command)}
}
func (gui *Gui) handleServiceRemoveMenu(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -211,16 +157,31 @@ func (gui *Gui) handleServiceRemoveMenu(g *gocui.Gui, v *gocui.View) error {
description: gui.Tr.RemoveWithVolumes,
command: fmt.Sprintf("%s rm --stop --force -v %s", composeCommand, service.Name),
},
{
description: gui.Tr.Cancel,
},
}
return gui.createServiceCommandMenu(options, gui.Tr.RemovingStatus)
menuItems := lo.Map(options, func(option *commandOption, _ int) *types.MenuItem {
return &types.MenuItem{
LabelColumns: option.getDisplayStrings(),
OnPress: func() error {
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
if err := gui.OSCommand.RunCommand(option.command); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
},
}
})
return gui.Menu(CreateMenuOptions{
Title: "",
Items: menuItems,
})
}
func (gui *Gui) handleServicePause(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -232,7 +193,7 @@ func (gui *Gui) handleServicePause(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleServiceStop(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -249,7 +210,7 @@ func (gui *Gui) handleServiceStop(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleServiceUp(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -264,7 +225,7 @@ func (gui *Gui) handleServiceUp(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleServiceRestart(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -279,7 +240,7 @@ func (gui *Gui) handleServiceRestart(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleServiceStart(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -294,7 +255,7 @@ func (gui *Gui) handleServiceStart(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleServiceAttach(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -312,7 +273,7 @@ func (gui *Gui) handleServiceAttach(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleServiceRenderLogsToMain(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -356,7 +317,7 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error {
{
description: gui.Tr.Down,
command: downCommand,
f: func() error {
onPress: func() error {
return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error {
if err := gui.OSCommand.RunCommand(downCommand); err != nil {
return gui.createErrorPanel(err.Error())
@ -368,7 +329,7 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error {
{
description: gui.Tr.DownWithVolumes,
command: downWithVolumesCommand,
f: func() error {
onPress: func() error {
return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error {
if err := gui.OSCommand.RunCommand(downWithVolumesCommand); err != nil {
return gui.createErrorPanel(err.Error())
@ -377,19 +338,23 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error {
})
},
},
{
description: gui.Tr.Cancel,
f: func() error { return nil },
},
}
handleMenuPress := func(index int) error { return options[index].f() }
menuItems := lo.Map(options, func(option *commandOption, _ int) *types.MenuItem {
return &types.MenuItem{
LabelColumns: option.getDisplayStrings(),
OnPress: option.onPress,
}
})
return gui.createMenu("", options, len(options), handleMenuPress)
return gui.Menu(CreateMenuOptions{
Title: "",
Items: menuItems,
})
}
func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -411,7 +376,7 @@ func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error {
gui.Config.UserConfig.CommandTemplates.RestartService,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}),
),
f: func() error {
onPress: func() error {
return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error {
if err := service.Restart(); err != nil {
return gui.createErrorPanel(err.Error())
@ -426,7 +391,7 @@ func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error {
gui.Config.UserConfig.CommandTemplates.RecreateService,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}),
),
f: func() error {
onPress: func() error {
return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error {
if err := gui.OSCommand.RunCommand(recreateCommand); err != nil {
return gui.createErrorPanel(err.Error())
@ -441,40 +406,27 @@ func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error {
gui.Config.UserConfig.CommandTemplates.RebuildService,
gui.DockerCommand.NewCommandObject(commands.CommandObject{Service: service}),
),
f: func() error {
onPress: func() error {
return gui.runSubprocess(gui.OSCommand.RunCustomCommand(rebuildCommand))
},
},
{
description: gui.Tr.Cancel,
f: func() error { return nil },
},
}
handleMenuPress := func(index int) error { return options[index].f() }
return gui.createMenu("", options, len(options), handleMenuPress)
}
func (gui *Gui) createServiceCommandMenu(options []*commandOption, status string) error {
handleMenuPress := func(index int) error {
if options[index].command == "" {
return nil
menuItems := lo.Map(options, func(option *commandOption, _ int) *types.MenuItem {
return &types.MenuItem{
LabelColumns: option.getDisplayStrings(),
OnPress: option.onPress,
}
return gui.WithWaitingStatus(status, func() error {
if err := gui.OSCommand.RunCommand(options[index].command); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
}
})
return gui.createMenu("", options, len(options), handleMenuPress)
return gui.Menu(CreateMenuOptions{
Title: "",
Items: menuItems,
})
}
func (gui *Gui) handleServicesCustomCommand(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -518,7 +470,7 @@ func (gui *Gui) handleServicesBulkCommand(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleServicesExecShell(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
@ -532,7 +484,7 @@ func (gui *Gui) handleServicesExecShell(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) handleServicesOpenInBrowserCommand(g *gocui.Gui, v *gocui.View) error {
service, err := gui.getSelectedService()
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}

@ -0,0 +1,148 @@
package gui
import (
"sort"
"testing"
dockerTypes "github.com/docker/docker/api/types"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/stretchr/testify/assert"
)
func sampleContainers() []*commands.Container {
return []*commands.Container{
{
ID: "1",
Name: "1",
Container: dockerTypes.Container{
State: "exited",
},
},
{
ID: "2",
Name: "2",
Container: dockerTypes.Container{
State: "running",
},
},
{
ID: "3",
Name: "3",
Container: dockerTypes.Container{
State: "running",
},
},
{
ID: "4",
Name: "4",
Container: dockerTypes.Container{
State: "created",
},
},
}
}
func expectedPerStatusContainers() []*commands.Container {
return []*commands.Container{
{
ID: "2",
Name: "2",
Container: dockerTypes.Container{
State: "running",
},
},
{
ID: "3",
Name: "3",
Container: dockerTypes.Container{
State: "running",
},
},
{
ID: "1",
Name: "1",
Container: dockerTypes.Container{
State: "exited",
},
},
{
ID: "4",
Name: "4",
Container: dockerTypes.Container{
State: "created",
},
},
}
}
func expectedLegacySortedContainers() []*commands.Container {
return []*commands.Container{
{
ID: "1",
Name: "1",
Container: dockerTypes.Container{
State: "exited",
},
},
{
ID: "2",
Name: "2",
Container: dockerTypes.Container{
State: "running",
},
},
{
ID: "3",
Name: "3",
Container: dockerTypes.Container{
State: "running",
},
},
{
ID: "4",
Name: "4",
Container: dockerTypes.Container{
State: "created",
},
},
}
}
func assertEqualContainers(t *testing.T, left *commands.Container, right *commands.Container) {
t.Helper()
assert.Equal(t, left.Container.State, right.Container.State)
assert.Equal(t, left.Container.ID, right.Container.ID)
assert.Equal(t, left.Name, right.Name)
}
func TestSortContainers(t *testing.T) {
actual := sampleContainers()
expected := expectedPerStatusContainers()
sort.Slice(actual, func(i, j int) bool {
return sortContainers(actual[i], actual[j], false)
})
assert.Equal(t, len(actual), len(expected))
for i := 0; i < len(actual); i++ {
assertEqualContainers(t, expected[i], actual[i])
}
}
func TestLegacySortedContainers(t *testing.T) {
actual := sampleContainers()
expected := expectedLegacySortedContainers()
sort.Slice(actual, func(i, j int) bool {
return sortContainers(actual[i], actual[j], true)
})
assert.Equal(t, len(actual), len(expected))
for i := 0; i < len(actual); i++ {
assertEqualContainers(t, expected[i], actual[i])
}
}

@ -0,0 +1,101 @@
package gui
import (
"context"
"time"
"github.com/jesseduffield/lazydocker/pkg/tasks"
)
func (gui *Gui) QueueTask(f func(ctx context.Context)) error {
return gui.taskManager.NewTask(f)
}
type RenderStringTaskOpts struct {
Autoscroll bool
Wrap bool
GetStrContent func() string
}
type TaskOpts struct {
Autoscroll bool
Wrap bool
Func func(ctx context.Context)
}
type TickerTaskOpts struct {
Duration time.Duration
Before func(ctx context.Context)
Func func(ctx context.Context, notifyStopped chan struct{})
Autoscroll bool
Wrap bool
}
func (gui *Gui) NewRenderStringTask(opts RenderStringTaskOpts) tasks.TaskFunc {
taskOpts := TaskOpts{
Autoscroll: opts.Autoscroll,
Wrap: opts.Wrap,
Func: func(ctx context.Context) {
gui.RenderStringMain(opts.GetStrContent())
},
}
return gui.NewTask(taskOpts)
}
// assumes it's cheap to obtain the content (otherwise we would pass a function that returns the content)
func (gui *Gui) NewSimpleRenderStringTask(getContent func() string) tasks.TaskFunc {
return gui.NewRenderStringTask(RenderStringTaskOpts{
GetStrContent: getContent,
Autoscroll: false,
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
})
}
func (gui *Gui) NewTask(opts TaskOpts) tasks.TaskFunc {
return func(ctx context.Context) {
mainView := gui.Views.Main
mainView.Autoscroll = opts.Autoscroll
mainView.Wrap = opts.Wrap
opts.Func(ctx)
}
}
// NewTickerTask is a convenience function for making a new task that repeats some action once per e.g. second
// the before function gets called after the lock is obtained, but before the ticker starts.
// if you handle a message on the stop channel in f() you need to send a message on the notifyStopped channel because returning is not sufficient. Here, unlike in a regular task, simply returning means we're now going to wait till the next tick to run again.
func (gui *Gui) NewTickerTask(opts TickerTaskOpts) tasks.TaskFunc {
notifyStopped := make(chan struct{}, 10)
task := func(ctx context.Context) {
if opts.Before != nil {
opts.Before(ctx)
}
tickChan := time.NewTicker(opts.Duration)
defer tickChan.Stop()
// calling f first so that we're not waiting for the first tick
opts.Func(ctx, notifyStopped)
for {
select {
case <-notifyStopped:
gui.Log.Info("exiting ticker task due to notifyStopped channel")
return
case <-ctx.Done():
gui.Log.Info("exiting ticker task due to stopped channel")
return
case <-tickChan.C:
gui.Log.Info("running ticker task again")
opts.Func(ctx, notifyStopped)
}
}
}
taskOpts := TaskOpts{
Autoscroll: opts.Autoscroll,
Wrap: opts.Wrap,
Func: task,
}
return gui.NewTask(taskOpts)
}

@ -0,0 +1,13 @@
package types
type MenuItem struct {
Label string
// alternative to Label. Allows specifying columns which will be auto-aligned
LabelColumns []string
OnPress func() error
// Only applies when Label is used
OpensMenu bool
}

@ -6,23 +6,25 @@ import (
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/gui/panels"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/samber/lo"
"github.com/spkg/bom"
)
func (gui *Gui) nextView(g *gocui.Gui, v *gocui.View) error {
sideViewNames := gui.sideViewNames()
var focusedViewName string
if v == nil || v.Name() == gui.CyclableViews[len(gui.CyclableViews)-1] {
focusedViewName = gui.CyclableViews[0]
if v == nil || v.Name() == sideViewNames[len(sideViewNames)-1] {
focusedViewName = sideViewNames[0]
} else {
viewName := v.Name()
for i := range gui.CyclableViews {
if viewName == gui.CyclableViews[i] {
focusedViewName = gui.CyclableViews[i+1]
for i := range sideViewNames {
if viewName == sideViewNames[i] {
focusedViewName = sideViewNames[i+1]
break
}
if i == len(gui.CyclableViews)-1 {
if i == len(sideViewNames)-1 {
gui.Log.Info("not in list of views")
return nil
}
@ -37,17 +39,18 @@ func (gui *Gui) nextView(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) previousView(g *gocui.Gui, v *gocui.View) error {
sideViewNames := gui.sideViewNames()
var focusedViewName string
if v == nil || v.Name() == gui.CyclableViews[0] {
focusedViewName = gui.CyclableViews[len(gui.CyclableViews)-1]
if v == nil || v.Name() == sideViewNames[0] {
focusedViewName = sideViewNames[len(sideViewNames)-1]
} else {
viewName := v.Name()
for i := range gui.CyclableViews {
if viewName == gui.CyclableViews[i] {
focusedViewName = gui.CyclableViews[i-1]
for i := range sideViewNames {
if viewName == sideViewNames[i] {
focusedViewName = sideViewNames[i-1]
break
}
if i == len(gui.CyclableViews)-1 {
if i == len(sideViewNames)-1 {
gui.Log.Info("not in list of views")
return nil
}
@ -63,126 +66,7 @@ func (gui *Gui) previousView(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) resetMainView() {
gui.State.Panels.Main.ObjectKey = ""
gui.getMainView().Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
}
func (gui *Gui) newLineFocused(v *gocui.View) error {
if v == nil {
return nil
}
switch v.Name() {
case "menu":
return gui.handleMenuSelect(gui.g, v)
case "project":
return gui.handleProjectSelect(gui.g, v)
case "services":
return gui.handleServiceSelect(gui.g, v)
case "containers":
return gui.handleContainerSelect(gui.g, v)
case "images":
return gui.handleImageSelect(gui.g, v)
case "volumes":
return gui.handleVolumeSelect(gui.g, v)
case "confirmation":
return nil
case "main":
v.Highlight = false
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
}
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)
}
// 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
gui.State.ViewStack = lo.Filter(gui.State.ViewStack, func(viewName string, _ int) bool {
return viewName != "confirmation" && viewName != "menu"
})
// 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()
gui.Views.Main.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
}
// if the cursor down past the last item, move it to the last line
@ -223,10 +107,15 @@ func (gui *Gui) focusPoint(selectedX int, selectedY int, lineCount int, v *gocui
}
}
func (gui *Gui) focusY(selectedY int, lineCount int, v *gocui.View) {
func (gui *Gui) FocusY(selectedY int, lineCount int, v *gocui.View) {
gui.focusPoint(0, selectedY, lineCount, v)
}
func (gui *Gui) ResetOrigin(v *gocui.View) {
_ = v.SetOrigin(0, 0)
_ = v.SetCursor(0, 0)
}
func (gui *Gui) cleanString(s string) string {
output := string(bom.Clean([]byte(s)))
return utils.NormalizeLinefeeds(output)
@ -256,6 +145,10 @@ func (gui *Gui) renderString(g *gocui.Gui, viewName, s string) error {
return nil
}
func (gui *Gui) RenderStringMain(s string) {
_ = gui.renderString(gui.g, "main", s)
}
// reRenderString sets the main view's content, without changing its origin
func (gui *Gui) reRenderStringMain(s string) {
gui.reRenderString("main", s)
@ -285,34 +178,8 @@ func (gui *Gui) renderOptionsMap(optionsMap map[string]string) error {
return gui.renderString(gui.g, "options", gui.optionsMapToString(optionsMap))
}
func (gui *Gui) getProjectView() *gocui.View {
v, _ := gui.g.View("project")
return v
}
func (gui *Gui) getServicesView() *gocui.View {
v, _ := gui.g.View("services")
return v
}
func (gui *Gui) getContainersView() *gocui.View {
v, _ := gui.g.View("containers")
return v
}
func (gui *Gui) getImagesView() *gocui.View {
v, _ := gui.g.View("images")
return v
}
func (gui *Gui) getVolumesView() *gocui.View {
v, _ := gui.g.View("volumes")
return v
}
func (gui *Gui) getMainView() *gocui.View {
v, _ := gui.g.View("main")
return v
func (gui *Gui) GetMainView() *gocui.View {
return gui.Views.Main
}
func (gui *Gui) trimmedContent(v *gocui.View) string {
@ -331,40 +198,24 @@ func (gui *Gui) currentViewName() string {
func (gui *Gui) resizeCurrentPopupPanel(g *gocui.Gui) error {
v := g.CurrentView()
if gui.isPopupPanel(v.Name()) {
return gui.resizePopupPanel(g, v)
return gui.resizePopupPanel(v)
}
return nil
}
func (gui *Gui) resizePopupPanel(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) resizePopupPanel(v *gocui.View) error {
// If the confirmation panel is already displayed, just resize the width,
// otherwise continue
content := v.Buffer()
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(g, v.Wrap, content)
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(v.Wrap, content)
vx0, vy0, vx1, vy1 := v.Dimensions()
if vx0 == x0 && vy0 == y0 && vx1 == x1 && vy1 == y1 {
return nil
}
_, err := g.SetView(v.Name(), x0, y0, x1, y1, 0)
_, err := gui.g.SetView(v.Name(), x0, y0, x1, y1, 0)
return err
}
func (gui *Gui) changeSelectedLine(line *int, total int, up bool) {
if up {
if *line == -1 || *line == 0 {
return
}
*line -= 1
} else {
if *line == -1 || *line == total-1 {
return
}
*line += 1
}
}
func (gui *Gui) renderPanelOptions() error {
currentView := gui.g.CurrentView()
switch currentView.Name() {
@ -377,7 +228,7 @@ func (gui *Gui) renderPanelOptions() error {
}
func (gui *Gui) isPopupPanel(viewName string) bool {
return viewName == "confirmation" || viewName == "menu"
return lo.Contains(gui.popupViewNames(), viewName)
}
func (gui *Gui) popupPanelFocused() bool {
@ -385,19 +236,22 @@ func (gui *Gui) popupPanelFocused() bool {
}
func (gui *Gui) clearMainView() {
mainView := gui.getMainView()
mainView := gui.Views.Main
mainView.Clear()
_ = mainView.SetOrigin(0, 0)
_ = mainView.SetCursor(0, 0)
}
func (gui *Gui) handleClick(v *gocui.View, itemCount int, selectedLine *int, handleSelect func(*gocui.Gui, *gocui.View) error) error {
if gui.popupPanelFocused() && v != nil && !gui.isPopupPanel(v.Name()) {
return nil
func (gui *Gui) HandleClick(v *gocui.View, itemCount int, selectedLine *int, handleSelect func() error) error {
wrappedHandleSelect := func(g *gocui.Gui, v *gocui.View) error {
return handleSelect()
}
return gui.handleClickAux(v, itemCount, selectedLine, wrappedHandleSelect)
}
if _, err := gui.g.SetCurrentView(v.Name()); err != nil {
return err
func (gui *Gui) handleClickAux(v *gocui.View, itemCount int, selectedLine *int, handleSelect func(*gocui.Gui, *gocui.View) error) error {
if gui.popupPanelFocused() && v != nil && !gui.isPopupPanel(v.Name()) {
return nil
}
_, cy := v.Cursor()
@ -415,6 +269,12 @@ func (gui *Gui) handleClick(v *gocui.View, itemCount int, selectedLine *int, han
*selectedLine = newSelectedLine
if gui.currentViewName() != v.Name() {
if err := gui.switchFocus(v); err != nil {
return err
}
}
return handleSelect(gui.g, v)
}
@ -465,3 +325,50 @@ func prevIntInCycle(sl []WindowMaximisation, current WindowMaximisation) WindowM
}
return sl[len(sl)-1]
}
func (gui *Gui) CurrentView() *gocui.View {
return gui.g.CurrentView()
}
func (gui *Gui) currentSidePanel() (panels.ISideListPanel, bool) {
viewName := gui.currentViewName()
for _, sidePanel := range gui.allSidePanels() {
if sidePanel.GetView().Name() == viewName {
return sidePanel, true
}
}
return nil, false
}
// returns the current list panel. If no list panel is focused, returns false.
func (gui *Gui) currentListPanel() (panels.ISideListPanel, bool) {
viewName := gui.currentViewName()
for _, sidePanel := range gui.allListPanels() {
if sidePanel.GetView().Name() == viewName {
return sidePanel, true
}
}
return nil, false
}
func (gui *Gui) allSidePanels() []panels.ISideListPanel {
return []panels.ISideListPanel{
gui.Panels.Projects,
gui.Panels.Services,
gui.Panels.Containers,
gui.Panels.Images,
gui.Panels.Volumes,
}
}
func (gui *Gui) allListPanels() []panels.ISideListPanel {
return append(gui.allSidePanels(), gui.Panels.Menu)
}
func (gui *Gui) IsCurrentView(view *gocui.View) bool {
return view == gui.CurrentView()
}

@ -3,53 +3,70 @@ package gui
import (
"github.com/fatih/color"
"github.com/jesseduffield/gocui"
"github.com/samber/lo"
)
type Views struct {
// side panels
Project *gocui.View
Services *gocui.View
Containers *gocui.View
Images *gocui.View
Volumes *gocui.View
// main panel
Main *gocui.View
Options *gocui.View
// bottom line
Options *gocui.View
Information *gocui.View
AppStatus *gocui.View
// text that prompts you to enter text in the Filter view
FilterPrefix *gocui.View
// appears next to the SearchPrefix view, it's where you type in the search string
Filter *gocui.View
// popups
Confirmation *gocui.View
Menu *gocui.View
Information *gocui.View
AppStatus *gocui.View
Limit *gocui.View
// will cover everything when it appears
Limit *gocui.View
}
type viewNameMapping struct {
viewPtr **gocui.View
name string
// if true, we handle the position/size of the view in arrangement.go. Otherwise
// we handle it manually.
autoPosition bool
}
func (gui *Gui) orderedViewNameMappings() []viewNameMapping {
return []viewNameMapping{
// first layer. Ordering within this layer does not matter because there are
// no overlapping views
{viewPtr: &gui.Views.Project, name: "project"},
{viewPtr: &gui.Views.Services, name: "services"},
{viewPtr: &gui.Views.Containers, name: "containers"},
{viewPtr: &gui.Views.Images, name: "images"},
{viewPtr: &gui.Views.Volumes, name: "volumes"},
{viewPtr: &gui.Views.Project, name: "project", autoPosition: true},
{viewPtr: &gui.Views.Services, name: "services", autoPosition: true},
{viewPtr: &gui.Views.Containers, name: "containers", autoPosition: true},
{viewPtr: &gui.Views.Images, name: "images", autoPosition: true},
{viewPtr: &gui.Views.Volumes, name: "volumes", autoPosition: true},
{viewPtr: &gui.Views.Main, name: "main"},
{viewPtr: &gui.Views.Main, name: "main", autoPosition: true},
// bottom line
{viewPtr: &gui.Views.Options, name: "options"},
{viewPtr: &gui.Views.AppStatus, name: "appStatus"},
{viewPtr: &gui.Views.Information, name: "information"},
{viewPtr: &gui.Views.Options, name: "options", autoPosition: true},
{viewPtr: &gui.Views.AppStatus, name: "appStatus", autoPosition: true},
{viewPtr: &gui.Views.Information, name: "information", autoPosition: true},
{viewPtr: &gui.Views.Filter, name: "filter", autoPosition: true},
{viewPtr: &gui.Views.FilterPrefix, name: "filterPrefix", autoPosition: true},
// popups.
{viewPtr: &gui.Views.Menu, name: "menu"},
{viewPtr: &gui.Views.Confirmation, name: "confirmation"},
{viewPtr: &gui.Views.Menu, name: "menu", autoPosition: false},
{viewPtr: &gui.Views.Confirmation, name: "confirmation", autoPosition: false},
// this guy will cover everything else when it appears
{viewPtr: &gui.Views.Limit, name: "limit"},
{viewPtr: &gui.Views.Limit, name: "limit", autoPosition: true},
}
}
@ -100,10 +117,6 @@ func (gui *Gui) createAllViews() error {
gui.Views.Information.Frame = false
gui.Views.Information.FgColor = gocui.ColorGreen
if err := gui.renderString(gui.g, "information", gui.getInformationContent()); err != nil {
return err
}
gui.Views.Confirmation.Visible = false
gui.Views.Confirmation.Wrap = true
gui.Views.Menu.Visible = false
@ -113,7 +126,25 @@ func (gui *Gui) createAllViews() error {
gui.Views.Limit.Title = gui.Tr.NotEnoughSpace
gui.Views.Limit.Wrap = true
gui.waitForIntro.Done()
gui.Views.FilterPrefix.BgColor = gocui.ColorDefault
gui.Views.FilterPrefix.FgColor = gocui.ColorGreen
gui.Views.FilterPrefix.Frame = false
gui.Views.Filter.BgColor = gocui.ColorDefault
gui.Views.Filter.FgColor = gocui.ColorGreen
gui.Views.Filter.Editable = true
gui.Views.Filter.Frame = false
gui.Views.Filter.Editor = gocui.EditorFunc(gui.wrapEditor(gocui.SimpleEditor))
return nil
}
func (gui *Gui) setInitialViewContent() error {
if err := gui.renderString(gui.g, "information", gui.getInformationContent()); err != nil {
return err
}
_ = gui.setViewContent(gui.Views.FilterPrefix, gui.filterPrompt())
return nil
}
@ -133,6 +164,12 @@ func (gui *Gui) popupViewNames() []string {
}
// these views have their position and size determined by arrangement.go
func (gui *Gui) controlledBoundsViewNames() []string {
return []string{"project", "services", "containers", "images", "volumes", "options", "information", "appStatus", "main", "limit"}
func (gui *Gui) autoPositionedViewNames() []string {
views := lo.Filter(gui.orderedViewNameMappings(), func(viewNameMapping viewNameMapping, _ int) bool {
return viewNameMapping.autoPosition
})
return lo.Map(views, func(viewNameMapping viewNameMapping, _ int) string {
return viewNameMapping.name
})
}

@ -4,238 +4,149 @@ import (
"fmt"
"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"
)
// list panel functions
func (gui *Gui) getVolumeContexts() []string {
return []string{"config"}
}
func (gui *Gui) getVolumeContextTitles() []string {
return []string{gui.Tr.ConfigTitle}
}
func (gui *Gui) getSelectedVolume() (*commands.Volume, error) {
selectedLine := gui.State.Panels.Volumes.SelectedLine
if selectedLine == -1 {
return nil, gui.Errors.ErrNoVolumes
func (gui *Gui) getVolumesPanel() *panels.SideListPanel[*commands.Volume] {
return &panels.SideListPanel[*commands.Volume]{
ContextState: &panels.ContextState[*commands.Volume]{
GetMainTabs: func() []panels.MainTab[*commands.Volume] {
return []panels.MainTab[*commands.Volume]{
{
Key: "config",
Title: gui.Tr.ConfigTitle,
Render: gui.renderVolumeConfig,
},
}
},
GetItemContextCacheKey: func(volume *commands.Volume) string {
return "volumes-" + volume.Name
},
},
ListPanel: panels.ListPanel[*commands.Volume]{
List: panels.NewFilteredList[*commands.Volume](),
View: gui.Views.Volumes,
},
NoItemsMessage: gui.Tr.NoVolumes,
Gui: gui.intoInterface(),
// we're sorting these volumes 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.Volume, b *commands.Volume) bool {
if len(a.Volume.Labels) == 0 && len(b.Volume.Labels) > 0 {
return false
}
if len(a.Volume.Labels) > 0 && len(b.Volume.Labels) == 0 {
return true
}
return a.Name < b.Name
},
GetTableCells: presentation.GetVolumeDisplayStrings,
}
return gui.DockerCommand.Volumes[selectedLine], nil
}
func (gui *Gui) handleVolumesClick(g *gocui.Gui, v *gocui.View) error {
itemCount := len(gui.DockerCommand.Volumes)
handleSelect := gui.handleVolumeSelect
selectedLine := &gui.State.Panels.Volumes.SelectedLine
return gui.handleClick(v, itemCount, selectedLine, handleSelect)
func (gui *Gui) renderVolumeConfig(volume *commands.Volume) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string { return gui.volumeConfigStr(volume) })
}
func (gui *Gui) handleVolumeSelect(g *gocui.Gui, v *gocui.View) error {
volume, err := gui.getSelectedVolume()
if err != nil {
if err != gui.Errors.ErrNoVolumes {
return err
}
return gui.renderString(g, "main", gui.Tr.NoVolumes)
}
gui.focusY(gui.State.Panels.Volumes.SelectedLine, len(gui.DockerCommand.Volumes), v)
func (gui *Gui) volumeConfigStr(volume *commands.Volume) string {
padding := 15
output := ""
output += utils.WithPadding("Name: ", padding) + volume.Name + "\n"
output += utils.WithPadding("Driver: ", padding) + volume.Volume.Driver + "\n"
output += utils.WithPadding("Scope: ", padding) + volume.Volume.Scope + "\n"
output += utils.WithPadding("Mountpoint: ", padding) + volume.Volume.Mountpoint + "\n"
output += utils.WithPadding("Labels: ", padding) + utils.FormatMap(padding, volume.Volume.Labels) + "\n"
output += utils.WithPadding("Options: ", padding) + utils.FormatMap(padding, volume.Volume.Options) + "\n"
key := "volumes-" + volume.Name + "-" + gui.getVolumeContexts()[gui.State.Panels.Volumes.ContextIndex]
if !gui.shouldRefresh(key) {
return nil
}
mainView := gui.getMainView()
mainView.Tabs = gui.getVolumeContextTitles()
mainView.TabIndex = gui.State.Panels.Volumes.ContextIndex
switch gui.getVolumeContexts()[gui.State.Panels.Volumes.ContextIndex] {
case "config":
if err := gui.renderVolumeConfig(mainView, volume); err != nil {
return err
output += utils.WithPadding("Status: ", padding)
if volume.Volume.Status != nil {
output += "\n"
for k, v := range volume.Volume.Status {
output += utils.FormatMapItem(padding, k, v)
}
default:
return errors.New("Unknown context for Volumes panel")
}
return nil
}
func (gui *Gui) renderVolumeConfig(mainView *gocui.View, volume *commands.Volume) error {
return gui.T.NewTask(func(stop chan struct{}) {
mainView.Autoscroll = false
mainView.Wrap = gui.Config.UserConfig.Gui.WrapMainPanel
padding := 15
output := ""
output += utils.WithPadding("Name: ", padding) + volume.Name + "\n"
output += utils.WithPadding("Driver: ", padding) + volume.Volume.Driver + "\n"
output += utils.WithPadding("Scope: ", padding) + volume.Volume.Scope + "\n"
output += utils.WithPadding("Mountpoint: ", padding) + volume.Volume.Mountpoint + "\n"
output += utils.WithPadding("Labels: ", padding) + utils.FormatMap(padding, volume.Volume.Labels) + "\n"
output += utils.WithPadding("Options: ", padding) + utils.FormatMap(padding, volume.Volume.Options) + "\n"
output += utils.WithPadding("Status: ", padding)
if volume.Volume.Status != nil {
output += "\n"
for k, v := range volume.Volume.Status {
output += utils.FormatMapItem(padding, k, v)
}
} else {
output += "n/a"
}
if volume.Volume.UsageData != nil {
output += utils.WithPadding("RefCount: ", padding) + fmt.Sprintf("%d", volume.Volume.UsageData.RefCount) + "\n"
output += utils.WithPadding("Size: ", padding) + utils.FormatBinaryBytes(int(volume.Volume.UsageData.Size)) + "\n"
}
_ = gui.renderString(gui.g, "main", output)
})
}
func (gui *Gui) refreshVolumes() error {
volumesView := gui.getVolumesView()
if volumesView == nil {
// if the volumesView hasn't been instantiated yet we just return
return nil
}
if err := gui.DockerCommand.RefreshVolumes(); err != nil {
return err
}
if len(gui.DockerCommand.Volumes) > 0 && gui.State.Panels.Volumes.SelectedLine == -1 {
gui.State.Panels.Volumes.SelectedLine = 0
}
if len(gui.DockerCommand.Volumes)-1 < gui.State.Panels.Volumes.SelectedLine {
gui.State.Panels.Volumes.SelectedLine = len(gui.DockerCommand.Volumes) - 1
}
gui.g.Update(func(g *gocui.Gui) error {
volumesView.Clear()
isFocused := gui.g.CurrentView().Name() == "volumes"
list, err := utils.RenderList(gui.DockerCommand.Volumes, utils.IsFocused(isFocused))
if err != nil {
return err
}
fmt.Fprint(volumesView, list)
if volumesView == g.CurrentView() {
return gui.handleVolumeSelect(g, volumesView)
}
return nil
})
return nil
}
func (gui *Gui) handleVolumesNextLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() || gui.g.CurrentView() != v {
return nil
} else {
output += "n/a"
}
panelState := gui.State.Panels.Volumes
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.Volumes), false)
return gui.handleVolumeSelect(gui.g, v)
}
func (gui *Gui) handleVolumesPrevLine(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() || gui.g.CurrentView() != v {
return nil
if volume.Volume.UsageData != nil {
output += utils.WithPadding("RefCount: ", padding) + fmt.Sprintf("%d", volume.Volume.UsageData.RefCount) + "\n"
output += utils.WithPadding("Size: ", padding) + utils.FormatBinaryBytes(int(volume.Volume.UsageData.Size)) + "\n"
}
panelState := gui.State.Panels.Volumes
gui.changeSelectedLine(&panelState.SelectedLine, len(gui.DockerCommand.Volumes), true)
return gui.handleVolumeSelect(gui.g, v)
return output
}
func (gui *Gui) handleVolumesNextContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getVolumeContexts()
if gui.State.Panels.Volumes.ContextIndex >= len(contexts)-1 {
gui.State.Panels.Volumes.ContextIndex = 0
} else {
gui.State.Panels.Volumes.ContextIndex++
func (gui *Gui) reloadVolumes() error {
if err := gui.refreshStateVolumes(); err != nil {
return err
}
_ = gui.handleVolumeSelect(gui.g, v)
return nil
return gui.Panels.Volumes.RerenderList()
}
func (gui *Gui) handleVolumesPrevContext(g *gocui.Gui, v *gocui.View) error {
contexts := gui.getVolumeContexts()
if gui.State.Panels.Volumes.ContextIndex <= 0 {
gui.State.Panels.Volumes.ContextIndex = len(contexts) - 1
} else {
gui.State.Panels.Volumes.ContextIndex--
func (gui *Gui) refreshStateVolumes() error {
volumes, err := gui.DockerCommand.RefreshVolumes()
if err != nil {
return err
}
_ = gui.handleVolumeSelect(gui.g, v)
gui.Panels.Volumes.SetItems(volumes)
return nil
}
type removeVolumeOption struct {
description string
command string
force bool
runCommand bool
}
// GetDisplayStrings is a function.
func (r *removeVolumeOption) GetDisplayStrings(isFocused bool) []string {
return []string{r.description, color.New(color.FgRed).Sprint(r.command)}
}
func (gui *Gui) handleVolumesRemoveMenu(g *gocui.Gui, v *gocui.View) error {
volume, err := gui.getSelectedVolume()
volume, err := gui.Panels.Volumes.GetSelectedItem()
if err != nil {
return nil
}
type removeVolumeOption struct {
description string
command string
force bool
}
options := []*removeVolumeOption{
{
description: gui.Tr.Remove,
command: utils.WithShortSha("docker volume rm " + volume.Name),
force: false,
runCommand: true,
},
{
description: gui.Tr.ForceRemove,
command: utils.WithShortSha("docker volume rm --force " + volume.Name),
force: true,
runCommand: true,
},
{
description: gui.Tr.Cancel,
runCommand: false,
},
}
handleMenuPress := func(index int) error {
if !options[index].runCommand {
return nil
menuItems := lo.Map(options, func(option *removeVolumeOption, _ 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 := volume.Remove(option.force); err != nil {
return gui.createErrorPanel(err.Error())
}
return nil
})
},
}
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
if cerr := volume.Remove(options[index].force); cerr != nil {
return gui.createErrorPanel(cerr.Error())
}
return nil
})
}
})
return gui.createMenu("", options, len(options), handleMenuPress)
return gui.Menu(CreateMenuOptions{
Title: "",
Items: menuItems,
})
}
func (gui *Gui) handlePruneVolumes() error {
@ -251,7 +162,7 @@ func (gui *Gui) handlePruneVolumes() error {
}
func (gui *Gui) handleVolumesCustomCommand(g *gocui.Gui, v *gocui.View) error {
volume, err := gui.getSelectedVolume()
volume, err := gui.Panels.Volumes.GetSelectedItem()
if err != nil {
return nil
}

@ -12,6 +12,7 @@ type TranslationSet struct {
Execute string
Scroll string
Close string
Quit string
ErrorTitle string
NoViewMachingNewLineFocusedSwitchStatement string
OpenConfig string
@ -38,6 +39,7 @@ type TranslationSet struct {
Confirm string
Return string
FocusMain string
LcFilter string
StopContainer string
RestartingStatus string
StartingStatus string
@ -75,6 +77,7 @@ type TranslationSet struct {
NoContainer string
NoImages string
NoVolumes string
NoServices string
RemoveImage string
RemoveVolume string
RemoveWithoutPrune string
@ -97,6 +100,7 @@ type TranslationSet struct {
ExecShell string
RunCustomCommand string
ViewBulkCommands string
FilterList string
OpenInBrowser string
SortContainersByState string
@ -109,6 +113,7 @@ type TranslationSet struct {
ContainerConfigTitle string
ContainerEnvTitle string
NothingToDisplay string
NoContainerForService string
CannotDisplayEnvVariables string
No string
@ -116,6 +121,7 @@ type TranslationSet struct {
LcNextScreenMode string
LcPrevScreenMode string
FilterPrompt string
}
func englishSet() TranslationSet {
@ -147,9 +153,11 @@ func englishSet() TranslationSet {
Return: "return",
FocusMain: "focus main panel",
LcFilter: "filter list",
Navigate: "navigate",
Execute: "execute",
Close: "close",
Quit: "quit",
Menu: "menu",
MenuTitle: "Menu",
Scroll: "scroll",
@ -190,6 +198,7 @@ func englishSet() TranslationSet {
ExecShell: "exec shell",
RunCustomCommand: "run predefined custom command",
ViewBulkCommands: "view bulk commands",
FilterList: "filter list",
OpenInBrowser: "open in browser (first port is http)",
SortContainersByState: "sort containers by state",
@ -214,12 +223,14 @@ func englishSet() TranslationSet {
ContainerConfigTitle: "Container Config",
ContainerEnvTitle: "Container Env",
NothingToDisplay: "Nothing to display",
NoContainerForService: "No logs to show; service is not associated with a container",
CannotDisplayEnvVariables: "Something went wrong while displaying environment variables",
NoContainers: "No containers",
NoContainer: "No container",
NoImages: "No images",
NoVolumes: "No volumes",
NoServices: "No services",
ConfirmQuit: "Are you sure you want to quit?",
ConfirmUpProject: "Are you sure you want to 'up' your docker compose project?",
@ -239,5 +250,6 @@ func englishSet() TranslationSet {
LcNextScreenMode: "next screen mode (normal/half/fullscreen)",
LcPrevScreenMode: "prev screen mode",
FilterPrompt: "filter",
}
}

@ -1,32 +1,36 @@
package tasks
import (
"context"
"fmt"
"sync"
"time"
"github.com/jesseduffield/lazydocker/pkg/i18n"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
type TaskManager struct {
currentTask *Task
waitingMutex sync.Mutex
taskIDMutex sync.Mutex
waitingMutex deadlock.Mutex
taskIDMutex deadlock.Mutex
Log *logrus.Entry
Tr *i18n.TranslationSet
newTaskId int
}
type Task struct {
stop chan struct{}
ctx context.Context
cancel context.CancelFunc
stopped bool
stopMutex sync.Mutex
stopMutex deadlock.Mutex
notifyStopped chan struct{}
Log *logrus.Entry
f func(chan struct{})
f func(ctx context.Context)
}
type TaskFunc func(ctx context.Context)
func NewTaskManager(log *logrus.Entry, translationSet *i18n.TranslationSet) *TaskManager {
return &TaskManager{Log: log, Tr: translationSet}
}
@ -52,7 +56,7 @@ func (t *TaskManager) Close() {
}
}
func (t *TaskManager) NewTask(f func(stop chan struct{})) error {
func (t *TaskManager) NewTask(f func(ctx context.Context)) error {
go func() {
t.taskIDMutex.Lock()
t.newTaskId++
@ -65,7 +69,7 @@ func (t *TaskManager) NewTask(f func(stop chan struct{})) error {
return
}
stop := make(chan struct{}, 1) // we don't want to block on this in case the task already returned
ctx, cancel := context.WithCancel(context.Background())
notifyStopped := make(chan struct{})
if t.currentTask != nil {
@ -75,14 +79,15 @@ func (t *TaskManager) NewTask(f func(stop chan struct{})) error {
}
t.currentTask = &Task{
stop: stop,
ctx: ctx,
cancel: cancel,
notifyStopped: notifyStopped,
Log: t.Log,
f: f,
}
go func() {
f(stop)
f(ctx)
t.Log.Info("returned from function, closing notifyStopped")
close(notifyStopped)
}()
@ -97,7 +102,8 @@ func (t *Task) Stop() {
if t.stopped {
return
}
close(t.stop)
t.cancel()
t.Log.Info("closed stop channel, waiting for notifyStopped message")
<-t.notifyStopped
t.Log.Info("received notifystopped message")
@ -107,28 +113,28 @@ func (t *Task) Stop() {
// NewTickerTask is a convenience function for making a new task that repeats some action once per e.g. second
// the before function gets called after the lock is obtained, but before the ticker starts.
// if you handle a message on the stop channel in f() you need to send a message on the notifyStopped channel because returning is not sufficient. Here, unlike in a regular task, simply returning means we're now going to wait till the next tick to run again.
func (t *TaskManager) NewTickerTask(duration time.Duration, before func(stop chan struct{}), f func(stop, notifyStopped chan struct{})) error {
func (t *TaskManager) NewTickerTask(duration time.Duration, before func(ctx context.Context), f func(ctx context.Context, notifyStopped chan struct{})) error {
notifyStopped := make(chan struct{}, 10)
return t.NewTask(func(stop chan struct{}) {
return t.NewTask(func(ctx context.Context) {
if before != nil {
before(stop)
before(ctx)
}
tickChan := time.NewTicker(duration)
defer tickChan.Stop()
// calling f first so that we're not waiting for the first tick
f(stop, notifyStopped)
f(ctx, notifyStopped)
for {
select {
case <-notifyStopped:
t.Log.Info("exiting ticker task due to notifyStopped channel")
return
case <-stop:
case <-ctx.Done():
t.Log.Info("exiting ticker task due to stopped cahnnel")
return
case <-tickChan.C:
t.Log.Info("running ticker task again")
f(stop, notifyStopped)
f(ctx, notifyStopped)
}
}
})

@ -6,7 +6,6 @@ import (
"html/template"
"io"
"math"
"reflect"
"regexp"
"sort"
"strings"
@ -98,71 +97,6 @@ func Max(x, y int) int {
return y
}
type Displayable interface {
GetDisplayStrings(bool) []string
}
type RenderListConfig struct {
IsFocused bool
Header []string
}
func IsFocused(isFocused bool) func(c *RenderListConfig) {
return func(c *RenderListConfig) {
c.IsFocused = isFocused
}
}
func WithHeader(header []string) func(c *RenderListConfig) {
return func(c *RenderListConfig) {
c.Header = header
}
}
// RenderList takes a slice of items, confirms they implement the Displayable
// interface, then generates a list of their displaystrings to write to a panel's
// buffer
func RenderList(slice interface{}, options ...func(*RenderListConfig)) (string, error) {
config := &RenderListConfig{}
for _, option := range options {
option(config)
}
s := reflect.ValueOf(slice)
if s.Kind() != reflect.Slice {
return "", errors.New("RenderList given a non-slice type")
}
displayables := make([]Displayable, s.Len())
for i := 0; i < s.Len(); i++ {
value, ok := s.Index(i).Interface().(Displayable)
if !ok {
return "", errors.New("item does not implement the Displayable interface")
}
displayables[i] = value
}
return renderDisplayableList(displayables, *config)
}
// renderDisplayableList takes a list of displayable items, obtains their display
// strings via GetDisplayStrings() and then returns a single string containing
// each item's string representation on its own line, with appropriate horizontal
// padding between the item's own strings
func renderDisplayableList(items []Displayable, config RenderListConfig) (string, error) {
if len(items) == 0 {
return "", nil
}
stringArrays := getDisplayStringArrays(items, config.IsFocused)
if len(config.Header) > 0 {
stringArrays = append([][]string{config.Header}, stringArrays...)
}
return RenderTable(stringArrays)
}
// RenderTable takes an array of string arrays and returns a table containing the values
func RenderTable(stringArrays [][]string) (string, error) {
if len(stringArrays) == 0 {
@ -225,14 +159,6 @@ func displayArraysAligned(stringArrays [][]string) bool {
return true
}
func getDisplayStringArrays(displayables []Displayable, isFocused bool) [][]string {
stringArrays := make([][]string, len(displayables))
for i, item := range displayables {
stringArrays[i] = item.GetDisplayStrings(isFocused)
}
return stringArrays
}
func FormatBinaryBytes(b int) string {
n := float64(b)
units := []string{"B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}
@ -408,3 +334,8 @@ func IsValidHexValue(v string) bool {
return true
}
// Style used on menu items that open another menu
func OpensMenuStyle(str string) string {
return ColoredString(fmt.Sprintf("%s...", str), color.FgMagenta)
}

@ -3,6 +3,7 @@ package utils
import (
"testing"
"github.com/go-errors/errors"
"github.com/stretchr/testify/assert"
)
@ -173,166 +174,6 @@ func TestDisplayArraysAligned(t *testing.T) {
}
}
type myDisplayable struct {
strings []string
}
type myStruct struct{}
// GetDisplayStrings is a function.
func (d *myDisplayable) GetDisplayStrings(isFocused bool) []string {
if isFocused {
return append(d.strings, "blah")
}
return d.strings
}
// TestGetDisplayStringArrays is a function.
func TestGetDisplayStringArrays(t *testing.T) {
type scenario struct {
input []Displayable
isFocused bool
expected [][]string
}
scenarios := []scenario{
{
[]Displayable{
Displayable(&myDisplayable{[]string{"a", "b"}}),
Displayable(&myDisplayable{[]string{"c", "d"}}),
},
false,
[][]string{{"a", "b"}, {"c", "d"}},
},
{
[]Displayable{
Displayable(&myDisplayable{[]string{"a", "b"}}),
Displayable(&myDisplayable{[]string{"c", "d"}}),
},
true,
[][]string{{"a", "b", "blah"}, {"c", "d", "blah"}},
},
}
for _, s := range scenarios {
assert.EqualValues(t, s.expected, getDisplayStringArrays(s.input, s.isFocused))
}
}
// TestRenderDisplayableList is a function.
func TestRenderDisplayableList(t *testing.T) {
type scenario struct {
input []Displayable
config RenderListConfig
expectedString string
expectedErrorMessage string
}
scenarios := []scenario{
{
[]Displayable{
Displayable(&myDisplayable{[]string{}}),
Displayable(&myDisplayable{[]string{}}),
},
RenderListConfig{},
"\n",
"",
},
{
[]Displayable{
Displayable(&myDisplayable{[]string{"aa", "b"}}),
Displayable(&myDisplayable{[]string{"c", "d"}}),
},
RenderListConfig{},
"aa b\nc d",
"",
},
{
[]Displayable{
Displayable(&myDisplayable{[]string{"a"}}),
Displayable(&myDisplayable{[]string{"b", "c"}}),
},
RenderListConfig{},
"",
"Each item must return the same number of strings to display",
},
{
[]Displayable{
Displayable(&myDisplayable{[]string{"a"}}),
Displayable(&myDisplayable{[]string{"b"}}),
},
RenderListConfig{IsFocused: true},
"a blah\nb blah",
"",
},
}
for _, s := range scenarios {
str, err := renderDisplayableList(s.input, s.config)
assert.EqualValues(t, s.expectedString, str)
if s.expectedErrorMessage != "" {
assert.EqualError(t, err, s.expectedErrorMessage)
} else {
assert.NoError(t, err)
}
}
}
// TestRenderList is a function.
func TestRenderList(t *testing.T) {
type scenario struct {
input interface{}
options []func(*RenderListConfig)
expectedString string
expectedErrorMessage string
}
scenarios := []scenario{
{
[]*myDisplayable{
{[]string{"aa", "b"}},
{[]string{"c", "d"}},
},
nil,
"aa b\nc d",
"",
},
{
[]*myStruct{
{},
{},
},
nil,
"",
"item does not implement the Displayable interface",
},
{
&myStruct{},
nil,
"",
"RenderList given a non-slice type",
},
{
[]*myDisplayable{
{[]string{"a"}},
},
[]func(*RenderListConfig){IsFocused(true)},
"a blah",
"",
},
}
for _, s := range scenarios {
str, err := RenderList(s.input, s.options...)
assert.EqualValues(t, s.expectedString, str)
if s.expectedErrorMessage != "" {
assert.EqualError(t, err, s.expectedErrorMessage)
} else {
assert.NoError(t, err)
}
}
}
// TestGetPaddedDisplayStrings is a function.
func TestGetPaddedDisplayStrings(t *testing.T) {
type scenario struct {
@ -380,3 +221,39 @@ func TestGetPadWidths(t *testing.T) {
assert.EqualValues(t, s.expected, getPadWidths(s.stringArrays))
}
}
func TestRenderTable(t *testing.T) {
type scenario struct {
input [][]string
expected string
expectedErr error
}
scenarios := []scenario{
{
input: [][]string{{"a", "b"}, {"c", "d"}},
expected: "a b\nc d",
expectedErr: nil,
},
{
input: [][]string{{"aaaa", "b"}, {"c", "d"}},
expected: "aaaa b\nc d",
expectedErr: nil,
},
{
input: [][]string{{"a"}, {"c", "d"}},
expected: "",
expectedErr: errors.New("Each item must return the same number of strings to display"),
},
}
for _, s := range scenarios {
output, err := RenderTable(s.input)
assert.EqualValues(t, s.expected, output)
if s.expectedErr != nil {
assert.EqualError(t, err, s.expectedErr.Error())
} else {
assert.NoError(t, err)
}
}
}

@ -1,11 +1,11 @@
<img src="logos/tcell.png" style="float: right"/>
# ![Tcell](logos/tcell.png)
Please see [here](UKRAINE.md) for an important message for the people of Russia.
# Tcell
_Tcell_ is a _Go_ package that provides a cell based view for text terminals, like _XTerm_.
It was inspired by _termbox_, but includes many additional improvements.
[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua)
[![Linux](https://img.shields.io/github/workflow/status/gdamore/tcell/linux?logoColor=grey&logo=linux&label=)](https://github.com/gdamore/tcell/actions/workflows/linux.yml)
[![Windows](https://img.shields.io/github/workflow/status/gdamore/tcell/windows?logoColor=grey&logo=windows&label=)](https://github.com/gdamore/tcell/actions/workflows/windows.yml)
[![Apache License](https://img.shields.io/github/license/gdamore/tcell.svg?logoColor=silver&logo=opensourceinitiative&color=blue&label=)](https://github.com/gdamore/tcell/blob/master/LICENSE)
@ -13,6 +13,8 @@ It was inspired by _termbox_, but includes many additional improvements.
[![Discord](https://img.shields.io/discord/639503822733180969?label=&logo=discord)](https://discord.gg/urTTxDN)
[![Coverage](https://img.shields.io/codecov/c/github/gdamore/tcell?logoColor=grey&logo=codecov&label=)](https://codecov.io/gh/gdamore/tcell)
Please see [here](UKRAINE.md) for an important message for the people of Russia.
NOTE: This is version 2 of _Tcell_. There are breaking changes relative to version 1.
Version 1.x remains available using the import `github.com/gdamore/tcell`.

@ -1,6 +1,7 @@
//go:build windows
// +build windows
// Copyright 2021 The TCell Authors
// Copyright 2022 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use file except in compliance with the License.
@ -114,22 +115,23 @@ var (
// characters (Unicode) are in use. The documentation refers to them
// without this suffix, as the resolution is made via preprocessor.
var (
procReadConsoleInput = k32.NewProc("ReadConsoleInputW")
procWaitForMultipleObjects = k32.NewProc("WaitForMultipleObjects")
procCreateEvent = k32.NewProc("CreateEventW")
procSetEvent = k32.NewProc("SetEvent")
procGetConsoleCursorInfo = k32.NewProc("GetConsoleCursorInfo")
procSetConsoleCursorInfo = k32.NewProc("SetConsoleCursorInfo")
procSetConsoleCursorPosition = k32.NewProc("SetConsoleCursorPosition")
procSetConsoleMode = k32.NewProc("SetConsoleMode")
procGetConsoleMode = k32.NewProc("GetConsoleMode")
procGetConsoleScreenBufferInfo = k32.NewProc("GetConsoleScreenBufferInfo")
procFillConsoleOutputAttribute = k32.NewProc("FillConsoleOutputAttribute")
procFillConsoleOutputCharacter = k32.NewProc("FillConsoleOutputCharacterW")
procSetConsoleWindowInfo = k32.NewProc("SetConsoleWindowInfo")
procSetConsoleScreenBufferSize = k32.NewProc("SetConsoleScreenBufferSize")
procSetConsoleTextAttribute = k32.NewProc("SetConsoleTextAttribute")
procMessageBeep = u32.NewProc("MessageBeep")
procReadConsoleInput = k32.NewProc("ReadConsoleInputW")
procWaitForMultipleObjects = k32.NewProc("WaitForMultipleObjects")
procCreateEvent = k32.NewProc("CreateEventW")
procSetEvent = k32.NewProc("SetEvent")
procGetConsoleCursorInfo = k32.NewProc("GetConsoleCursorInfo")
procSetConsoleCursorInfo = k32.NewProc("SetConsoleCursorInfo")
procSetConsoleCursorPosition = k32.NewProc("SetConsoleCursorPosition")
procSetConsoleMode = k32.NewProc("SetConsoleMode")
procGetConsoleMode = k32.NewProc("GetConsoleMode")
procGetConsoleScreenBufferInfo = k32.NewProc("GetConsoleScreenBufferInfo")
procFillConsoleOutputAttribute = k32.NewProc("FillConsoleOutputAttribute")
procFillConsoleOutputCharacter = k32.NewProc("FillConsoleOutputCharacterW")
procSetConsoleWindowInfo = k32.NewProc("SetConsoleWindowInfo")
procSetConsoleScreenBufferSize = k32.NewProc("SetConsoleScreenBufferSize")
procSetConsoleTextAttribute = k32.NewProc("SetConsoleTextAttribute")
procGetLargestConsoleWindowSize = k32.NewProc("GetLargestConsoleWindowSize")
procMessageBeep = u32.NewProc("MessageBeep")
)
const (
@ -189,7 +191,7 @@ func (s *cScreen) Init() error {
s.in = in
out, e := syscall.Open("CONOUT$", syscall.O_RDWR, 0)
if e != nil {
syscall.Close(s.in)
_ = syscall.Close(s.in)
return e
}
s.out = out
@ -224,15 +226,15 @@ func (s *cScreen) Init() error {
s.resize()
s.fini = false
s.setInMode(modeResizeEn | modeExtndFlg)
s.setInMode(modeResizeEn | modeExtendFlg)
// 24-bit color is opt-in for now, because we can't figure out
// to make it work consistently.
if s.truecolor {
s.setOutMode(modeVtOutput | modeNoAutoNL | modeCookedOut)
var omode uint32
s.getOutMode(&omode)
if omode&modeVtOutput == modeVtOutput {
var om uint32
s.getOutMode(&om)
if om&modeVtOutput == modeVtOutput {
s.vten = true
} else {
s.truecolor = false
@ -268,9 +270,9 @@ func (s *cScreen) DisableMouse() {
func (s *cScreen) enableMouse(on bool) {
if on {
s.setInMode(modeResizeEn | modeMouseEn | modeExtndFlg)
s.setInMode(modeResizeEn | modeMouseEn | modeExtendFlg)
} else {
s.setInMode(modeResizeEn | modeExtndFlg)
s.setInMode(modeResizeEn | modeExtendFlg)
}
}
@ -292,7 +294,7 @@ func (s *cScreen) disengage() {
}
s.running = false
stopQ := s.stopQ
procSetEvent.Call(uintptr(s.cancelflag))
_, _, _ = procSetEvent.Call(uintptr(s.cancelflag))
close(stopQ)
s.Unlock()
@ -307,7 +309,7 @@ func (s *cScreen) disengage() {
s.clearScreen(StyleDefault, false)
s.setCursorPos(0, 0, false)
s.setCursorInfo(&s.ocursor)
procSetConsoleTextAttribute.Call(
_, _, _ = procSetConsoleTextAttribute.Call(
uintptr(s.out),
uintptr(s.mapStyle(StyleDefault)))
}
@ -421,7 +423,7 @@ type rect struct {
func (s *cScreen) emitVtString(vs string) {
esc := utf16.Encode([]rune(vs))
syscall.WriteConsole(s.out, &esc[0], uint32(len(esc)), nil, nil)
_ = syscall.WriteConsole(s.out, &esc[0], uint32(len(esc)), nil, nil)
}
func (s *cScreen) showCursor() {
@ -487,8 +489,8 @@ const (
keyEvent uint16 = 1
mouseEvent uint16 = 2
resizeEvent uint16 = 4
menuEvent uint16 = 8 // don't use
focusEvent uint16 = 16 // don't use
// menuEvent uint16 = 8 // don't use
// focusEvent uint16 = 16 // don't use
)
type mouseRecord struct {
@ -500,10 +502,10 @@ type mouseRecord struct {
}
const (
mouseDoubleClick uint32 = 0x2
mouseHWheeled uint32 = 0x8
mouseVWheeled uint32 = 0x4
mouseMoved uint32 = 0x1
mouseHWheeled uint32 = 0x8
mouseVWheeled uint32 = 0x4
// mouseDoubleClick uint32 = 0x2
// mouseMoved uint32 = 0x1
)
type resizeRecord struct {
@ -590,6 +592,8 @@ var vkKeys = map[uint16]Key{
vkInsert: KeyInsert,
vkDelete: KeyDelete,
vkHelp: KeyHelp,
vkEscape: KeyEscape,
vkSpace: ' ',
vkF1: KeyF1,
vkF2: KeyF2,
vkF3: KeyF3,
@ -806,11 +810,11 @@ func (s *cScreen) scanInput(stopQ chan struct{}) {
}
}
// Windows console can display 8 characters, in either low or high intensity
func (s *cScreen) Colors() int {
if s.vten {
return 1 << 24
}
// Windows console can display 8 colors, in either low or high intensity
return 16
}
@ -868,10 +872,10 @@ func (s *cScreen) mapStyle(style Style) uint16 {
// views.
if a&AttrReverse != 0 {
attr = ba
attr |= (fa << 4)
attr |= fa << 4
} else {
attr = fa
attr |= (ba << 4)
attr |= ba << 4
}
if a&AttrBold != 0 {
attr |= 0x8
@ -895,19 +899,19 @@ func (s *cScreen) SetCell(x, y int, style Style, ch ...rune) {
}
}
func (s *cScreen) SetContent(x, y int, mainc rune, combc []rune, style Style) {
func (s *cScreen) SetContent(x, y int, primary rune, combining []rune, style Style) {
s.Lock()
if !s.fini {
s.cells.SetContent(x, y, mainc, combc, style)
s.cells.SetContent(x, y, primary, combining, style)
}
s.Unlock()
}
func (s *cScreen) GetContent(x, y int) (rune, []rune, Style, int) {
s.Lock()
mainc, combc, style, width := s.cells.GetContent(x, y)
primary, combining, style, width := s.cells.GetContent(x, y)
s.Unlock()
return mainc, combc, style, width
return primary, combining, style, width
}
func (s *cScreen) sendVtStyle(style Style) {
@ -931,15 +935,15 @@ func (s *cScreen) sendVtStyle(style Style) {
}
if fg.IsRGB() {
r, g, b := fg.RGB()
fmt.Fprintf(esc, vtSetFgRGB, r, g, b)
_, _ = fmt.Fprintf(esc, vtSetFgRGB, r, g, b)
} else if fg.Valid() {
fmt.Fprintf(esc, vtSetFg, fg&0xff)
_, _ = fmt.Fprintf(esc, vtSetFg, fg&0xff)
}
if bg.IsRGB() {
r, g, b := bg.RGB()
fmt.Fprintf(esc, vtSetBgRGB, r, g, b)
_, _ = fmt.Fprintf(esc, vtSetBgRGB, r, g, b)
} else if bg.Valid() {
fmt.Fprintf(esc, vtSetBg, bg&0xff)
_, _ = fmt.Fprintf(esc, vtSetBg, bg&0xff)
}
s.emitVtString(esc.String())
}
@ -954,16 +958,16 @@ func (s *cScreen) writeString(x, y int, style Style, ch []uint16) {
if s.vten {
s.sendVtStyle(style)
} else {
procSetConsoleTextAttribute.Call(
_, _, _ = procSetConsoleTextAttribute.Call(
uintptr(s.out),
uintptr(s.mapStyle(style)))
}
syscall.WriteConsole(s.out, &ch[0], uint32(len(ch)), nil, nil)
_ = syscall.WriteConsole(s.out, &ch[0], uint32(len(ch)), nil, nil)
}
func (s *cScreen) draw() {
// allocate a scratch line bit enough for no combining chars.
// if you have combining characters, you may pay for extra allocs.
// if you have combining characters, you may pay for extra allocations.
if s.clear {
s.clearScreen(s.style, s.vten)
s.clear = false
@ -1053,19 +1057,19 @@ type consoleInfo struct {
}
func (s *cScreen) getConsoleInfo(info *consoleInfo) {
procGetConsoleScreenBufferInfo.Call(
_, _, _ = procGetConsoleScreenBufferInfo.Call(
uintptr(s.out),
uintptr(unsafe.Pointer(info)))
}
func (s *cScreen) getCursorInfo(info *cursorInfo) {
procGetConsoleCursorInfo.Call(
_, _, _ = procGetConsoleCursorInfo.Call(
uintptr(s.out),
uintptr(unsafe.Pointer(info)))
}
func (s *cScreen) setCursorInfo(info *cursorInfo) {
procSetConsoleCursorInfo.Call(
_, _, _ = procSetConsoleCursorInfo.Call(
uintptr(s.out),
uintptr(unsafe.Pointer(info)))
@ -1076,14 +1080,14 @@ func (s *cScreen) setCursorPos(x, y int, vtEnable bool) {
// Note that the string is Y first. Origin is 1,1.
s.emitVtString(fmt.Sprintf(vtCursorPos, y+1, x+1))
} else {
procSetConsoleCursorPosition.Call(
_, _, _ = procSetConsoleCursorPosition.Call(
uintptr(s.out),
coord{int16(x), int16(y)}.uintptr())
}
}
func (s *cScreen) setBufferSize(x, y int) {
procSetConsoleScreenBufferSize.Call(
_, _, _ = procSetConsoleScreenBufferSize.Call(
uintptr(s.out),
coord{int16(x), int16(y)}.uintptr())
}
@ -1096,6 +1100,37 @@ func (s *cScreen) Size() (int, int) {
return w, h
}
func (s *cScreen) SetSize(w, h int) {
xy, _, _ := procGetLargestConsoleWindowSize.Call(uintptr(s.out))
// xy is little endian packed
y := int(xy >> 16)
x := int(xy & 0xffff)
if x == 0 || y == 0 {
return
}
// This is a hacky workaround for Windows Terminal.
// Essentially Windows Terminal (Windows 11) does not support application
// initiated resizing. To detect this, we look for an extremely large size
// for the maximum width. If it is > 500, then this is almost certainly
// Windows Terminal, and won't support this. (Note that the legacy console
// does support application resizing.)
if x >= 500 {
return
}
s.setBufferSize(x, y)
r := rect{0, 0, int16(w - 1), int16(h - 1)}
_, _, _ = procSetConsoleWindowInfo.Call(
uintptr(s.out),
uintptr(1),
uintptr(unsafe.Pointer(&r)))
s.resize()
}
func (s *cScreen) resize() {
info := consoleInfo{}
s.getConsoleInfo(&info)
@ -1114,11 +1149,11 @@ func (s *cScreen) resize() {
s.setBufferSize(w, h)
r := rect{0, 0, int16(w - 1), int16(h - 1)}
procSetConsoleWindowInfo.Call(
_, _, _ = procSetConsoleWindowInfo.Call(
uintptr(s.out),
uintptr(1),
uintptr(unsafe.Pointer(&r)))
s.PostEvent(NewEventResize(w, h))
_ = s.PostEvent(NewEventResize(w, h))
}
func (s *cScreen) Clear() {
@ -1151,13 +1186,13 @@ func (s *cScreen) clearScreen(style Style, vtEnable bool) {
scratch := uint32(0)
count := uint32(x * y)
procFillConsoleOutputAttribute.Call(
_, _, _ = procFillConsoleOutputAttribute.Call(
uintptr(s.out),
uintptr(attr),
uintptr(count),
pos.uintptr(),
uintptr(unsafe.Pointer(&scratch)))
procFillConsoleOutputCharacter.Call(
_, _, _ = procFillConsoleOutputCharacter.Call(
uintptr(s.out),
uintptr(' '),
uintptr(count),
@ -1168,47 +1203,39 @@ func (s *cScreen) clearScreen(style Style, vtEnable bool) {
const (
// Input modes
modeExtndFlg uint32 = 0x0080
modeMouseEn = 0x0010
modeResizeEn = 0x0008
modeCooked = 0x0001
modeVtInput = 0x0200
modeExtendFlg uint32 = 0x0080
modeMouseEn = 0x0010
modeResizeEn = 0x0008
// modeCooked = 0x0001
// modeVtInput = 0x0200
// Output modes
modeCookedOut uint32 = 0x0001
modeWrapEOL = 0x0002
modeVtOutput = 0x0004
modeNoAutoNL = 0x0008
// modeWrapEOL = 0x0002
)
func (s *cScreen) setInMode(mode uint32) error {
rv, _, err := procSetConsoleMode.Call(
func (s *cScreen) setInMode(mode uint32) {
_, _, _ = procSetConsoleMode.Call(
uintptr(s.in),
uintptr(mode))
if rv == 0 {
return err
}
return nil
}
func (s *cScreen) setOutMode(mode uint32) error {
rv, _, err := procSetConsoleMode.Call(
func (s *cScreen) setOutMode(mode uint32) {
_, _, _ = procSetConsoleMode.Call(
uintptr(s.out),
uintptr(mode))
if rv == 0 {
return err
}
return nil
}
func (s *cScreen) getInMode(v *uint32) {
procGetConsoleMode.Call(
_, _, _ = procGetConsoleMode.Call(
uintptr(s.in),
uintptr(unsafe.Pointer(v)))
}
func (s *cScreen) getOutMode(v *uint32) {
procGetConsoleMode.Call(
_, _, _ = procGetConsoleMode.Call(
uintptr(s.out),
uintptr(unsafe.Pointer(v)))
}
@ -1221,15 +1248,15 @@ func (s *cScreen) SetStyle(style Style) {
// No fallback rune support, since we have Unicode. Yay!
func (s *cScreen) RegisterRuneFallback(r rune, subst string) {
func (s *cScreen) RegisterRuneFallback(_ rune, _ string) {
}
func (s *cScreen) UnregisterRuneFallback(r rune) {
func (s *cScreen) UnregisterRuneFallback(_ rune) {
}
func (s *cScreen) CanDisplay(r rune, checkFallbacks bool) bool {
func (s *cScreen) CanDisplay(_ rune, _ bool) bool {
// We presume we can display anything -- we're Unicode.
// (Sadly this not precisely true. Combinings are especially
// (Sadly this not precisely true. Combining characters are especially
// poorly supported under Windows.)
return true
}

@ -27,7 +27,7 @@ type EventPaste struct {
t time.Time
}
// When returns the time when this EventMouse was created.
// When returns the time when this EventPaste was created.
func (ev *EventPaste) When() time.Time {
return ev.t
}

@ -1,4 +1,4 @@
// Copyright 2021 The TCell Authors
// Copyright 2022 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use file except in compliance with the License.
@ -43,7 +43,7 @@ type Screen interface {
// be displayed if Show() or Sync() is called. The width is the width
// in screen cells; most often this will be 1, but some East Asian
// characters require two cells.
GetContent(x, y int) (mainc rune, combc []rune, style Style, width int)
GetContent(x, y int) (primary rune, combining []rune, style Style, width int)
// SetContent sets the contents of the given cell location. If
// the coordinates are out of range, then the operation is ignored.
@ -52,13 +52,13 @@ type Screen interface {
// that follows is a possible list of combining characters to append,
// and will usually be nil (no combining characters.)
//
// The results are not displayd until Show() or Sync() is called.
// The results are not displayed until Show() or Sync() is called.
//
// Note that wide (East Asian full width) runes occupy two cells,
// and attempts to place character at next cell to the right will have
// undefined effects. Wide runes that are printed in the
// last column will be replaced with a single width space on output.
SetContent(x int, y int, mainc rune, combc []rune, style Style)
SetContent(x int, y int, primary rune, combining []rune, style Style)
// SetStyle sets the default style to use when clearing the screen
// or when StyleDefault is specified. If it is also StyleDefault,
@ -70,7 +70,7 @@ type Screen interface {
// dimensions of the screen, the cursor will be hidden.
ShowCursor(x int, y int)
// HideCursor is used to hide the cursor. Its an alias for
// HideCursor is used to hide the cursor. It's an alias for
// ShowCursor(-1, -1).sim
HideCursor()
@ -139,7 +139,7 @@ type Screen interface {
DisablePaste()
// HasMouse returns true if the terminal (apparently) supports a
// mouse. Note that the a return value of true doesn't guarantee that
// mouse. Note that the return value of true doesn't guarantee that
// a mouse/pointing device is present; a false return definitely
// indicates no mouse support is available.
HasMouse() bool
@ -161,8 +161,8 @@ type Screen interface {
// internal model. This may be both expensive and visually jarring,
// so it should only be used when believed to actually be necessary.
//
// Typically this is called as a result of a user-requested redraw
// (e.g. to clear up on screen corruption caused by some other program),
// Typically, this is called as a result of a user-requested redraw
// (e.g. to clear up on-screen corruption caused by some other program),
// or during a resize event.
Sync()
@ -178,13 +178,13 @@ type Screen interface {
// o as a fallback for ø. This should be done cautiously for
// characters that might be displayed ordinarily in language
// specific text -- characters that could change the meaning of
// of written text would be dangerous. The intention here is to
// written text would be dangerous. The intention here is to
// facilitate fallback characters in pseudo-graphical applications.
//
// If the terminal has fallbacks already in place via an alternate
// character set, those are used in preference. Also, standard
// fallbacks for graphical characters in the ACSC terminfo string
// are registered implicitly.
// fallbacks for graphical characters in the alternate character set
// terminfo string are registered implicitly.
//
// The display string should be the same width as original rune.
// This makes it possible to register two character replacements
@ -203,7 +203,7 @@ type Screen interface {
UnregisterRuneFallback(r rune)
// CanDisplay returns true if the given rune can be displayed on
// this screen. Note that this is a best guess effort -- whether
// this screen. Note that this is a best-guess effort -- whether
// your fonts support the character or not may be questionable.
// Mostly this is for folks who work outside of Unicode.
//
@ -213,7 +213,7 @@ type Screen interface {
// one that is visually indistinguishable from the one requested.
CanDisplay(r rune, checkFallbacks bool) bool
// Resize does nothing, since its generally not possible to
// Resize does nothing, since it's generally not possible to
// ask a screen to resize, but it allows the Screen to implement
// the View interface.
Resize(int, int, int, int)
@ -239,6 +239,15 @@ type Screen interface {
// Beep attempts to sound an OS-dependent audible alert and returns an error
// when unsuccessful.
Beep() error
// SetSize attempts to resize the window. It also invalidates the cells and
// calls the resize function. Note that if the window size is changed, it will
// not be restored upon application exit.
//
// Many terminals cannot support this. Perversely, the "modern" Windows Terminal
// does not support application-initiated resizing, whereas the legacy terminal does.
// Also, some emulators can support this but may have it disabled by default.
SetSize(int, int)
}
// NewScreen returns a default Screen suitable for the user's terminal
@ -255,7 +264,7 @@ func NewScreen() (Screen, error) {
}
// MouseFlags are options to modify the handling of mouse events.
// Actual events can be or'd together.
// Actual events can be ORed together.
type MouseFlags int
const (
@ -265,7 +274,7 @@ const (
)
// CursorStyle represents a given cursor style, which can include the shape and
// whether the cursor blinks or is solid. Support for changing these is not universal.
// whether the cursor blinks or is solid. Support for changing this is not universal.
type CursorStyle int
const (
@ -276,4 +285,4 @@ const (
CursorStyleSteadyUnderline
CursorStyleBlinkingBar
CursorStyleSteadyBar
)
)

@ -1,4 +1,4 @@
// Copyright 2021 The TCell Authors
// Copyright 2022 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use file except in compliance with the License.
@ -49,13 +49,6 @@ type SimulationScreen interface {
// InjectMouse injects a mouse event.
InjectMouse(x, y int, buttons ButtonMask, mod ModMask)
// SetSize resizes the underlying physical screen. It also causes
// a resize event to be injected during the next Show() or Sync().
// A new physical contents array will be allocated (with data from
// the old copied), so any prior value obtained with GetContents
// won't be used anymore
SetSize(width, height int)
// GetContents returns screen contents as an array of
// cells, along with the physical width & height. Note that the
// physical contents will be used until the next time SetSize()

@ -1,4 +1,4 @@
// Copyright 2020 The TCell Authors
// Copyright 2022 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use file except in compliance with the License.
@ -26,6 +26,7 @@ type Style struct {
fg Color
bg Color
attrs AttrMask
url string
}
// StyleDefault represents a default style, based upon the context.
@ -42,6 +43,7 @@ func (s Style) Foreground(c Color) Style {
fg: c,
bg: s.bg,
attrs: s.attrs,
url: s.url,
}
}
@ -52,11 +54,12 @@ func (s Style) Background(c Color) Style {
fg: s.fg,
bg: c,
attrs: s.attrs,
url: s.url,
}
}
// Decompose breaks a style up, returning the foreground, background,
// and other attributes.
// and other attributes. The URL if set is not included.
func (s Style) Decompose() (fg Color, bg Color, attr AttrMask) {
return s.fg, s.bg, s.attrs
}
@ -67,12 +70,14 @@ func (s Style) setAttrs(attrs AttrMask, on bool) Style {
fg: s.fg,
bg: s.bg,
attrs: s.attrs | attrs,
url: s.url,
}
}
return Style{
fg: s.fg,
bg: s.bg,
attrs: s.attrs &^ attrs,
url: s.url,
}
}
@ -133,5 +138,18 @@ func (s Style) Attributes(attrs AttrMask) Style {
fg: s.fg,
bg: s.bg,
attrs: attrs,
url: s.url,
}
}
// Url returns a style with the Url set. If the provided Url is not empty,
// and the terminal supports it, text will typically be marked up as a clickable
// link to that Url. If the Url is empty, then this mode is turned off.
func (s Style) Url(url string) Style {
return Style{
fg: s.fg,
bg: s.bg,
attrs: s.attrs,
url: url,
}
}

@ -1,4 +1,4 @@
// Copyright 2021 The TCell Authors
// Copyright 2022 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use file except in compliance with the License.
@ -227,6 +227,9 @@ type Terminfo struct {
CursorSteadyUnderline string
CursorBlinkingBar string
CursorSteadyBar string
EnterUrl string
ExitUrl string
SetWindowSize string
}
const (
@ -234,93 +237,75 @@ const (
ModifiersXTerm = 1
)
type stackElem struct {
s string
i int
isStr bool
isInt bool
}
type stack []interface{}
type stack []stackElem
func (st stack) Push(v interface{}) stack {
return append(st, v)
}
func (st stack) Push(v string) stack {
e := stackElem{
s: v,
isStr: true,
func (st stack) Pop() (interface{}, stack) {
if len(st) > 0 {
e := st[len(st)-1]
return e, st[:len(st)-1]
}
return append(st, e)
return 0, st
}
func (st stack) Pop() (string, stack) {
v := ""
func (st stack) PopString() (string, stack) {
if len(st) > 0 {
e := st[len(st)-1]
st = st[:len(st)-1]
if e.isStr {
v = e.s
} else {
v = strconv.Itoa(e.i)
var s string
switch v := e.(type) {
case int:
s = strconv.Itoa(v)
case bool:
s = strconv.FormatBool(v)
case string:
s = v
}
return s, st[:len(st)-1]
}
return v, st
}
return "", st
}
func (st stack) PopInt() (int, stack) {
if len(st) > 0 {
e := st[len(st)-1]
st = st[:len(st)-1]
if e.isInt {
return e.i, st
} else if e.isStr {
// If the string that was pushed was the representation
// of a number e.g. '123', then return the number. If the
// conversion doesn't work, assume the string pushed was
// intended to return, as an int, the ascii representation
// of the (one and only) character.
i, err := strconv.Atoi(e.s)
if err == nil {
return i, st
} else if len(e.s) >= 1 {
return int(e.s[0]), st
var i int
switch v := e.(type) {
case int:
i = v
case bool:
if v {
i = 1
} else {
i = 0
}
case string:
i, _ = strconv.Atoi(v)
}
return i, st[:len(st)-1]
}
return 0, st
}
func (st stack) PopBool() (bool, stack) {
var b bool
if len(st) > 0 {
e := st[len(st)-1]
st = st[:len(st)-1]
if e.isStr {
if e.s == "1" {
return true, st
}
return false, st
} else if e.i == 1 {
return true, st
} else {
return false, st
switch v := e.(type) {
case int:
b = v != 0
case bool:
b = v
case string:
b = v != "" && v != "false"
}
return b, st[:len(st)-1]
}
return false, st
}
func (st stack) PushInt(i int) stack {
e := stackElem{
i: i,
isInt: true,
}
return append(st, e)
}
func (st stack) PushBool(i bool) stack {
if i {
return st.PushInt(1)
}
return st.PushInt(0)
}
// static vars
var svars [26]string
@ -372,13 +357,13 @@ var pb = &paramsBuffer{}
// TParm takes a terminfo parameterized string, such as setaf or cup, and
// evaluates the string, and returns the result with the parameter
// applied.
func (t *Terminfo) TParm(s string, p ...int) string {
func (t *Terminfo) TParm(s string, p ...interface{}) string {
var stk stack
var a, b string
var ai, bi int
var ab bool
var dvars [26]string
var params [9]int
var params [9]interface{}
pb.Start(s)
@ -413,14 +398,18 @@ func (t *Terminfo) TParm(s string, p ...int) string {
pb.PutCh(ch)
case 'i': // increment both parameters (ANSI cup support)
params[0]++
params[1]++
if i, ok := params[0].(int); ok {
params[0] = i + 1
}
if i, ok := params[1].(int); ok {
params[1] = i + 1
}
case 'c', 's':
// NB: these, and 'd' below are special cased for
// efficiency. They could be handled by the richer
// format support below, less efficiently.
a, stk = stk.Pop()
a, stk = stk.PopString()
pb.PutString(a)
case 'd':
@ -431,7 +420,7 @@ func (t *Terminfo) TParm(s string, p ...int) string {
// This is pretty suboptimal, but this is rarely used.
// None of the mainstream terminals use any of this,
// and it would surprise me if this code is ever
// executed outside of test cases.
// executed outside test cases.
f := "%"
if ch == ':' {
ch, _ = pb.NextCh()
@ -450,7 +439,7 @@ func (t *Terminfo) TParm(s string, p ...int) string {
ai, stk = stk.PopInt()
pb.PutString(fmt.Sprintf(f, ai))
case 'c', 's':
a, stk = stk.Pop()
a, stk = stk.PopString()
pb.PutString(fmt.Sprintf(f, a))
}
@ -458,17 +447,17 @@ func (t *Terminfo) TParm(s string, p ...int) string {
ch, _ = pb.NextCh()
ai = int(ch - '1')
if ai >= 0 && ai < len(params) {
stk = stk.PushInt(params[ai])
stk = stk.Push(params[ai])
} else {
stk = stk.PushInt(0)
stk = stk.Push(0)
}
case 'P': // pop & store variable
ch, _ = pb.NextCh()
if ch >= 'A' && ch <= 'Z' {
svars[int(ch-'A')], stk = stk.Pop()
svars[int(ch-'A')], stk = stk.PopString()
} else if ch >= 'a' && ch <= 'z' {
dvars[int(ch-'a')], stk = stk.Pop()
dvars[int(ch-'a')], stk = stk.PopString()
}
case 'g': // recall & push variable
@ -481,7 +470,7 @@ func (t *Terminfo) TParm(s string, p ...int) string {
case '\'': // push(char)
ch, _ = pb.NextCh()
pb.NextCh() // must be ' but we don't check
_, _ = pb.NextCh() // must be ' but we don't check
stk = stk.Push(string(ch))
case '{': // push(int)
@ -493,82 +482,82 @@ func (t *Terminfo) TParm(s string, p ...int) string {
ch, _ = pb.NextCh()
}
// ch must be '}' but no verification
stk = stk.PushInt(ai)
stk = stk.Push(ai)
case 'l': // push(strlen(pop))
a, stk = stk.Pop()
stk = stk.PushInt(len(a))
a, stk = stk.PopString()
stk = stk.Push(len(a))
case '+':
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
stk = stk.PushInt(ai + bi)
stk = stk.Push(ai + bi)
case '-':
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
stk = stk.PushInt(ai - bi)
stk = stk.Push(ai - bi)
case '*':
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
stk = stk.PushInt(ai * bi)
stk = stk.Push(ai * bi)
case '/':
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
if bi != 0 {
stk = stk.PushInt(ai / bi)
stk = stk.Push(ai / bi)
} else {
stk = stk.PushInt(0)
stk = stk.Push(0)
}
case 'm': // push(pop mod pop)
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
if bi != 0 {
stk = stk.PushInt(ai % bi)
stk = stk.Push(ai % bi)
} else {
stk = stk.PushInt(0)
stk = stk.Push(0)
}
case '&': // AND
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
stk = stk.PushInt(ai & bi)
stk = stk.Push(ai & bi)
case '|': // OR
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
stk = stk.PushInt(ai | bi)
stk = stk.Push(ai | bi)
case '^': // XOR
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
stk = stk.PushInt(ai ^ bi)
stk = stk.Push(ai ^ bi)
case '~': // bit complement
ai, stk = stk.PopInt()
stk = stk.PushInt(ai ^ -1)
stk = stk.Push(ai ^ -1)
case '!': // logical NOT
ai, stk = stk.PopInt()
stk = stk.PushBool(ai != 0)
stk = stk.Push(ai != 0)
case '=': // numeric compare or string compare
b, stk = stk.Pop()
a, stk = stk.Pop()
stk = stk.PushBool(a == b)
b, stk = stk.PopString()
a, stk = stk.PopString()
stk = stk.Push(a == b)
case '>': // greater than, numeric
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
stk = stk.PushBool(ai > bi)
stk = stk.Push(ai > bi)
case '<': // less than, numeric
bi, stk = stk.PopInt()
ai, stk = stk.PopInt()
stk = stk.PushBool(ai < bi)
stk = stk.Push(ai < bi)
case '?': // start conditional
@ -650,15 +639,15 @@ func (t *Terminfo) TPuts(w io.Writer, s string) {
beg := strings.Index(s, "$<")
if beg < 0 {
// Most strings don't need padding, which is good news!
io.WriteString(w, s)
_, _ = io.WriteString(w, s)
return
}
io.WriteString(w, s[:beg])
_, _ = io.WriteString(w, s[:beg])
s = s[beg+2:]
end := strings.Index(s, ">")
if end < 0 {
// unterminated.. just emit bytes unadulterated
io.WriteString(w, "$<"+s)
_, _ = io.WriteString(w, "$<"+s)
return
}
val := s[:end]
@ -729,7 +718,6 @@ func (t *Terminfo) TColor(fi, bi int) string {
var (
dblock sync.Mutex
terminfos = make(map[string]*Terminfo)
aliases = make(map[string]string)
)
// AddTerminfo can be called to register a new Terminfo entry.

@ -148,6 +148,9 @@ type tScreen struct {
finiOnce sync.Once
enablePaste string
disablePaste string
enterUrl string
exitUrl string
setWinSize string
cursorStyles map[CursorStyle]string
cursorStyle CursorStyle
saved *term.State
@ -334,6 +337,26 @@ func (t *tScreen) prepareBracketedPaste() {
}
}
func (t *tScreen) prepareExtendedOSC() {
// More stuff for limits in terminfo. This time we are applying
// the most common OSC (operating system commands). Generally
// terminals that don't understand these will ignore them.
// Again, we condition this based on mouse capabilities.
if t.ti.EnterUrl != "" {
t.enterUrl = t.ti.EnterUrl
t.exitUrl = t.ti.ExitUrl
} else if t.ti.Mouse != "" {
t.enterUrl = "\x1b]8;;%p1%s\x1b\\"
t.exitUrl = "\x1b]8;;\x1b\\"
}
if t.ti.SetWindowSize != "" {
t.setWinSize = t.ti.SetWindowSize
} else if t.ti.Mouse != "" {
t.setWinSize = "\x1b[8;%p1%p2%d;%dt"
}
}
func (t *tScreen) prepareCursorStyles() {
// Another workaround for lack of reporting in terminfo.
// We assume if the terminal has a mouse entry, that it
@ -502,6 +525,7 @@ func (t *tScreen) prepareKeys() {
t.prepareXtermModifiers()
t.prepareBracketedPaste()
t.prepareCursorStyles()
t.prepareExtendedOSC()
outer:
// Add key mappings for control keys.
@ -623,11 +647,27 @@ func (t *tScreen) encodeRune(r rune, buf []byte) []byte {
return buf
}
func (t *tScreen) sendFgBg(fg Color, bg Color) {
func (t *tScreen) sendFgBg(fg Color, bg Color, attr AttrMask) AttrMask {
ti := t.ti
if ti.Colors == 0 {
return
// foreground vs background, we calculate luminance
// and possibly do a reverse video
if !fg.Valid() {
return attr
}
v, ok := t.colors[fg]
if !ok {
v = FindColor(fg, []Color{ColorBlack, ColorWhite})
t.colors[fg] = v
}
switch v {
case ColorWhite:
return attr
case ColorBlack:
return attr ^ AttrReverse
}
}
if fg == ColorReset || bg == ColorReset {
t.TPuts(ti.ResetFgBg)
}
@ -638,7 +678,7 @@ func (t *tScreen) sendFgBg(fg Color, bg Color) {
t.TPuts(ti.TParm(ti.SetFgBgRGB,
int(r1), int(g1), int(b1),
int(r2), int(g2), int(b2)))
return
return attr
}
if fg.IsRGB() && ti.SetFgRGB != "" {
@ -685,6 +725,7 @@ func (t *tScreen) sendFgBg(fg Color, bg Color) {
t.TPuts(ti.TParm(ti.SetBg, int(bg&0xff)))
}
}
return attr
}
func (t *tScreen) drawCell(x, y int) int {
@ -727,7 +768,7 @@ func (t *tScreen) drawCell(x, y int) int {
t.TPuts(ti.AttrOff)
t.sendFgBg(fg, bg)
attrs = t.sendFgBg(fg, bg, attrs)
if attrs&AttrBold != 0 {
t.TPuts(ti.Bold)
}
@ -749,8 +790,19 @@ func (t *tScreen) drawCell(x, y int) int {
if attrs&AttrStrikeThrough != 0 {
t.TPuts(ti.StrikeThrough)
}
// URL string can be long, so don't send it unless we really need to
if t.enterUrl != "" && t.curstyle != style {
if style.url != "" {
t.TPuts(ti.TParm(t.enterUrl, style.url))
} else {
t.TPuts(t.exitUrl)
}
}
t.curstyle = style
}
// now emit runes - taking care to not overrun width with a
// wide character, and to ensure that we emit exactly one regular
// character followed up by any residual combing characters
@ -859,8 +911,9 @@ func (t *tScreen) Show() {
func (t *tScreen) clearScreen() {
t.TPuts(t.ti.AttrOff)
t.TPuts(t.exitUrl)
fg, bg, _ := t.style.Decompose()
t.sendFgBg(fg, bg)
_ = t.sendFgBg(fg, bg, AttrNone)
t.TPuts(t.ti.Clear)
t.clear = false
}
@ -1716,6 +1769,14 @@ func (t *tScreen) HasKey(k Key) bool {
return t.keyexist[k]
}
func (t *tScreen) SetSize(w, h int) {
if t.setWinSize != "" {
t.TPuts(t.ti.TParm(t.setWinSize, w, h))
}
t.cells.Invalidate()
t.resize()
}
func (t *tScreen) Resize(int, int, int, int) {}
func (t *tScreen) Suspend() error {

@ -24,10 +24,10 @@ func (f EditorFunc) Edit(v *View, key Key, ch rune, mod Modifier) bool {
}
// DefaultEditor is the default editor.
var DefaultEditor Editor = EditorFunc(simpleEditor)
var DefaultEditor Editor = EditorFunc(SimpleEditor)
// simpleEditor is used as the default gocui editor.
func simpleEditor(v *View, key Key, ch rune, mod Modifier) bool {
// SimpleEditor is used as the default gocui editor.
func SimpleEditor(v *View, key Key, ch rune, mod Modifier) bool {
switch {
case key == KeyBackspace || key == KeyBackspace2:
v.TextArea.BackSpaceChar()
@ -37,8 +37,12 @@ func simpleEditor(v *View, key Key, ch rune, mod Modifier) bool {
v.TextArea.MoveCursorDown()
case key == KeyArrowUp:
v.TextArea.MoveCursorUp()
case key == KeyArrowLeft && (mod&ModAlt) != 0:
v.TextArea.MoveLeftWord()
case key == KeyArrowLeft:
v.TextArea.MoveCursorLeft()
case key == KeyArrowRight && (mod&ModAlt) != 0:
v.TextArea.MoveRightWord()
case key == KeyArrowRight:
v.TextArea.MoveCursorRight()
case key == KeyEnter:
@ -49,10 +53,16 @@ func simpleEditor(v *View, key Key, ch rune, mod Modifier) bool {
v.TextArea.ToggleOverwrite()
case key == KeyCtrlU:
v.TextArea.DeleteToStartOfLine()
case key == KeyCtrlK:
v.TextArea.DeleteToEndOfLine()
case key == KeyCtrlA || key == KeyHome:
v.TextArea.GoToStartOfLine()
case key == KeyCtrlE || key == KeyEnd:
v.TextArea.GoToEndOfLine()
case key == KeyCtrlW:
v.TextArea.BackSpaceWord()
case key == KeyCtrlY:
v.TextArea.Yank()
// TODO: see if we need all three of these conditions: maybe the final one is sufficient
case ch != 0 && mod == 0 && unicode.IsPrint(ch):

@ -214,6 +214,8 @@ func (ei *escapeInterpreter) outputNormal() error {
case p == 0:
ei.curFgColor = ColorDefault
ei.curBgColor = ColorDefault
case p >= 21 && p <= 29:
ei.curFgColor &= ^getFontEffect(p - 20)
default:
ei.curFgColor |= getFontEffect(p)
}

@ -78,16 +78,13 @@ type ViewMouseBinding struct {
// the view that is clicked
ViewName string
// the context we are in when the click occurs. Not necessarily the context
// of the view we're clicking. If this is blank then it is a global binding.
FromContext string
// the context assigned to the clicked view. If blank, then we don't care
// what context is assigned
ToContext string
// the view that has focus when the click occurs.
FocusedView string
Handler func(ViewMouseBindingOpts) error
Modifier Modifier
// must be a mouse key
Key Key
}
@ -111,6 +108,8 @@ const (
NORMAL PlayMode = iota
RECORDING
REPLAYING
// for the new form of integration tests
REPLAYING_NEW
)
type Recording struct {
@ -119,8 +118,8 @@ type Recording struct {
}
type replayedEvents struct {
keys chan *TcellKeyEventWrapper
resizes chan *TcellResizeEventWrapper
Keys chan *TcellKeyEventWrapper
Resizes chan *TcellResizeEventWrapper
}
type RecordingConfig struct {
@ -191,8 +190,6 @@ type Gui struct {
screen tcell.Screen
suspendedMutex sync.Mutex
suspended bool
currentContext string
}
// NewGui returns a new Gui object with a given output mode.
@ -209,6 +206,16 @@ func NewGui(mode OutputMode, supportOverlaps bool, playMode PlayMode, headless b
return nil, err
}
if headless || runtime.GOOS == "windows" {
g.maxX, g.maxY = g.screen.Size()
} else {
// TODO: find out if we actually need this bespoke logic for linux
g.maxX, g.maxY, err = g.getTermWindowSize()
if err != nil {
return nil, err
}
}
g.outputMode = mode
g.stop = make(chan struct{})
@ -221,22 +228,13 @@ func NewGui(mode OutputMode, supportOverlaps bool, playMode PlayMode, headless b
KeyEvents: []*TcellKeyEventWrapper{},
ResizeEvents: []*TcellResizeEventWrapper{},
}
} else if playMode == REPLAYING {
} else if playMode == REPLAYING || playMode == REPLAYING_NEW {
g.ReplayedEvents = replayedEvents{
keys: make(chan *TcellKeyEventWrapper),
resizes: make(chan *TcellResizeEventWrapper),
Keys: make(chan *TcellKeyEventWrapper),
Resizes: make(chan *TcellResizeEventWrapper),
}
}
if runtime.GOOS != "windows" {
g.maxX, g.maxY, err = g.getTermWindowSize()
if err != nil {
return nil, err
}
} else {
g.maxX, g.maxY = Screen.Size()
}
g.BgColor, g.FgColor, g.FrameColor = ColorDefault, ColorDefault, ColorDefault
g.SelBgColor, g.SelFgColor, g.SelFrameColor = ColorDefault, ColorDefault, ColorDefault
@ -268,10 +266,6 @@ func (g *Gui) Size() (x, y int) {
return g.maxX, g.maxY
}
func (g *Gui) SetCurrentContext(context string) {
g.currentContext = context
}
// SetRune writes a rune at the given point, relative to the top-left
// corner of the terminal. It checks if the position is valid and applies
// the given colors.
@ -370,6 +364,60 @@ func (g *Gui) SetViewOnBottom(name string) (*View, error) {
return nil, errors.Wrap(ErrUnknownView, 0)
}
func (g *Gui) SetViewOnTopOf(toMove string, other string) error {
g.Mutexes.ViewsMutex.Lock()
defer g.Mutexes.ViewsMutex.Unlock()
if toMove == other {
return nil
}
// need to find the two current positions and then move toMove before other in the list.
toMoveIndex := -1
otherIndex := -1
for i, v := range g.views {
if v.name == toMove {
toMoveIndex = i
}
if v.name == other {
otherIndex = i
}
}
if toMoveIndex == -1 || otherIndex == -1 {
return errors.Wrap(ErrUnknownView, 0)
}
// already on top
if toMoveIndex > otherIndex {
return nil
}
// need to actually do it the other way around. Last is highest
viewToMove := g.views[toMoveIndex]
g.views = append(g.views[:toMoveIndex], g.views[toMoveIndex+1:]...)
g.views = append(g.views[:otherIndex], append([]*View{viewToMove}, g.views[otherIndex:]...)...)
return nil
}
// replaces the content in toView with the content in fromView
func (g *Gui) CopyContent(fromView *View, toView *View) {
g.Mutexes.ViewsMutex.Lock()
defer g.Mutexes.ViewsMutex.Unlock()
toView.clear()
toView.lines = fromView.lines
toView.viewLines = fromView.viewLines
toView.ox = fromView.ox
toView.oy = fromView.oy
toView.cx = fromView.cx
toView.cy = fromView.cy
}
// Views returns all the views in the GUI.
func (g *Gui) Views() []*View {
return g.views
@ -470,7 +518,7 @@ func (g *Gui) CurrentView() *View {
// It behaves differently on different platforms. Somewhere it doesn't register Alt key press,
// on others it might report Ctrl as Alt. It's not consistent and therefore it's not recommended
// to use with mouse keys.
func (g *Gui) SetKeybinding(viewname string, contexts []string, key interface{}, mod Modifier, handler func(*Gui, *View) error) error {
func (g *Gui) SetKeybinding(viewname string, key interface{}, mod Modifier, handler func(*Gui, *View) error) error {
var kb *keybinding
k, ch, err := getKey(key)
@ -482,7 +530,7 @@ func (g *Gui) SetKeybinding(viewname string, contexts []string, key interface{},
return ErrBlacklisted
}
kb = newKeybinding(viewname, contexts, k, ch, mod, handler)
kb = newKeybinding(viewname, k, ch, mod, handler)
g.keybindings = append(g.keybindings, kb)
return nil
}
@ -563,6 +611,8 @@ func (g *Gui) WhitelistKeybinding(k Key) error {
// typed Key or rune.
func getKey(key interface{}) (Key, rune, error) {
switch t := key.(type) {
case nil: // Ignore keybinding if `nil`
return 0, 0, nil
case Key:
return t, 0, nil
case rune:
@ -1158,9 +1208,11 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
if len(v.Tabs) > 0 {
tabIndex := v.GetClickedTabIndex(mx - v.x0)
for _, binding := range g.tabClickBindings {
if binding.viewName == v.Name() {
return binding.handler(tabIndex)
if tabIndex >= 0 {
for _, binding := range g.tabClickBindings {
if binding.viewName == v.Name() {
return binding.handler(tabIndex)
}
}
}
}
@ -1179,7 +1231,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
return err
}
if ev.Mod == ModNone && IsMouseKey(ev.Key) {
if IsMouseKey(ev.Key) {
opts := ViewMouseBindingOpts{X: newCx + v.ox, Y: newCy + v.oy}
matched, err := g.execMouseKeybindings(v, ev, opts)
if err != nil {
@ -1200,18 +1252,20 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) {
isMatch := func(binding *ViewMouseBinding) bool {
return binding.ViewName == view.Name() && ev.Key == binding.Key && (binding.ToContext == "" || binding.ToContext == view.Context)
return binding.ViewName == view.Name() &&
ev.Key == binding.Key &&
ev.Mod == binding.Modifier
}
// first pass looks for ones that match both the view and the from context
// first pass looks for ones that match the focused view
for _, binding := range g.viewMouseBindings {
if isMatch(binding) && binding.FromContext != "" && binding.FromContext == g.currentContext {
if isMatch(binding) && binding.FocusedView != "" && binding.FocusedView == g.currentView.Name() {
return true, binding.Handler(opts)
}
}
for _, binding := range g.viewMouseBindings {
if isMatch(binding) && binding.FromContext == "" {
if isMatch(binding) && binding.FocusedView == "" {
return true, binding.Handler(opts)
}
}
@ -1386,7 +1440,7 @@ func (g *Gui) replayRecording() {
case <-ticker.C:
timeWaited += 1
if timeWaited >= timeToWait {
g.ReplayedEvents.keys <- event
g.ReplayedEvents.Keys <- event
break middle
}
case <-g.stop:
@ -1419,7 +1473,7 @@ func (g *Gui) replayRecording() {
case <-ticker.C:
timeWaited += 1
if timeWaited >= timeToWait {
g.ReplayedEvents.resizes <- event
g.ReplayedEvents.Resizes <- event
break middle2
}
case <-g.stop:
@ -1483,14 +1537,5 @@ func (g *Gui) matchView(v *View, kb *keybinding) bool {
if kb.viewName != v.name {
return false
}
// if the keybinding doesn't specify contexts, it applies for all contexts
if len(kb.contexts) == 0 {
return true
}
for _, context := range kb.contexts {
if context == v.Context {
return true
}
}
return false
return true
}

@ -20,7 +20,6 @@ type Modifier tcell.ModMask
// Keybidings are used to link a given key-press event with a handler.
type keybinding struct {
viewName string
contexts []string
key Key
ch rune
mod Modifier
@ -93,10 +92,9 @@ func MustParseAll(input []string) map[interface{}]Modifier {
}
// newKeybinding returns a new Keybinding object.
func newKeybinding(viewname string, contexts []string, key Key, ch rune, mod Modifier, handler func(*Gui, *View) error) (kb *keybinding) {
func newKeybinding(viewname string, key Key, ch rune, mod Modifier, handler func(*Gui, *View) error) (kb *keybinding) {
kb = &keybinding{
viewName: viewname,
contexts: contexts,
key: key,
ch: ch,
mod: mod,

@ -84,6 +84,10 @@ func (g *Gui) tcellInitSimulation() error {
} else {
g.screen = s
Screen = s
// setting to a larger value than the typical terminal size
// so that during a test we're more likely to see an item to select in a view.
s.SetSize(100, 100)
s.Sync()
return nil
}
}
@ -232,11 +236,11 @@ func (g *Gui) timeSinceStart() int64 {
// pollEvent get tcell.Event and transform it into gocuiEvent
func (g *Gui) pollEvent() GocuiEvent {
var tev tcell.Event
if g.PlayMode == REPLAYING {
if g.PlayMode == REPLAYING || g.PlayMode == REPLAYING_NEW {
select {
case ev := <-g.ReplayedEvents.keys:
case ev := <-g.ReplayedEvents.Keys:
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.resizes:
case ev := <-g.ReplayedEvents.Resizes:
tev = (ev).toTcellEvent()
}
} else {

@ -1,11 +1,21 @@
package gocui
import "github.com/mattn/go-runewidth"
import (
"strings"
"github.com/mattn/go-runewidth"
)
const (
WHITESPACES = " \t"
WORD_SEPARATORS = "*?_+-.[]~=/&;!#$%^(){}<>"
)
type TextArea struct {
content []rune
cursor int
overwrite bool
clipboard string
}
func (self *TextArea) TypeRune(r rune) {
@ -54,6 +64,54 @@ func (self *TextArea) MoveCursorRight() {
self.cursor++
}
func (self *TextArea) MoveLeftWord() {
if self.cursor == 0 {
return
}
if self.atLineStart() {
self.cursor--
return
}
for !self.atLineStart() && strings.ContainsRune(WHITESPACES, self.content[self.cursor-1]) {
self.cursor--
}
separators := false
for !self.atLineStart() && strings.ContainsRune(WORD_SEPARATORS, self.content[self.cursor-1]) {
self.cursor--
separators = true
}
if !separators {
for !self.atLineStart() && !strings.ContainsRune(WHITESPACES+WORD_SEPARATORS, self.content[self.cursor-1]) {
self.cursor--
}
}
}
func (self *TextArea) MoveRightWord() {
if self.atEnd() {
return
}
if self.atLineEnd() {
self.cursor++
return
}
for !self.atLineEnd() && strings.ContainsRune(WHITESPACES, self.content[self.cursor]) {
self.cursor++
}
separators := false
for !self.atLineEnd() && strings.ContainsRune(WORD_SEPARATORS, self.content[self.cursor]) {
self.cursor++
separators = true
}
if !separators {
for !self.atLineEnd() && !strings.ContainsRune(WHITESPACES+WORD_SEPARATORS, self.content[self.cursor]) {
self.cursor++
}
}
}
func (self *TextArea) MoveCursorUp() {
x, y := self.GetCursorXY()
self.SetCursor2D(x, y-1)
@ -92,10 +150,25 @@ func (self *TextArea) DeleteToStartOfLine() {
// otherwise, you delete everything up to the start of the current line, without
// deleting the newline character
newlineIndex := self.closestNewlineOnLeft()
self.clipboard = string(self.content[newlineIndex+1 : self.cursor])
self.content = append(self.content[:newlineIndex+1], self.content[self.cursor:]...)
self.cursor = newlineIndex + 1
}
func (self *TextArea) DeleteToEndOfLine() {
if self.atEnd() {
return
}
if self.atLineEnd() {
self.content = append(self.content[:self.cursor], self.content[self.cursor+1:]...)
return
}
lineEndIndex := self.closestNewlineOnRight()
self.clipboard = string(self.content[self.cursor:lineEndIndex])
self.content = append(self.content[:self.cursor], self.content[lineEndIndex:]...)
}
func (self *TextArea) GoToStartOfLine() {
if self.atLineStart() {
return
@ -142,6 +215,43 @@ func (self *TextArea) atLineStart() bool {
(len(self.content) > self.cursor-1 && self.content[self.cursor-1] == '\n')
}
func (self *TextArea) atLineEnd() bool {
return self.atEnd() ||
(len(self.content) > self.cursor && self.content[self.cursor] == '\n')
}
func (self *TextArea) BackSpaceWord() {
if self.cursor == 0 {
return
}
if self.atLineStart() {
self.BackSpaceChar()
return
}
right := self.cursor
for !self.atLineStart() && strings.ContainsRune(WHITESPACES, self.content[self.cursor-1]) {
self.cursor--
}
separators := false
for !self.atLineStart() && strings.ContainsRune(WORD_SEPARATORS, self.content[self.cursor-1]) {
self.cursor--
separators = true
}
if !separators {
for !self.atLineStart() && !strings.ContainsRune(WHITESPACES+WORD_SEPARATORS, self.content[self.cursor-1]) {
self.cursor--
}
}
self.clipboard = string(self.content[self.cursor:right])
self.content = append(self.content[:self.cursor], self.content[right:]...)
}
func (self *TextArea) Yank() {
self.TypeString(self.clipboard)
}
func (self *TextArea) GetCursorXY() (int, int) {
cursorX := 0
cursorY := 0

@ -146,8 +146,6 @@ type View struct {
// ParentView is the view which catches events bubbled up from the given view if there's no matching handler
ParentView *View
Context string // this is for assigning keybindings to a view only in certain contexts
searcher *searcher
// KeybindOnEdit should be set to true when you want to execute keybindings even when the view is editable
@ -467,6 +465,14 @@ func (v *View) Cursor() (x, y int) {
return v.cx, v.cy
}
func (v *View) CursorX() int {
return v.cx
}
func (v *View) CursorY() int {
return v.cy
}
// SetOrigin sets the origin position of the view's internal buffer,
// so the buffer starts to be printed from this point, which means that
// it is linked with the origin point of view. It can be used to
@ -595,6 +601,14 @@ func (v *View) writeCells(x, y int, cells []cell) {
v.lines[y] = line[:newLen]
}
// readCell gets cell at specified location (x, y)
func (v *View) readCell(x, y int) (cell, bool) {
if y < 0 || y >= len(v.lines) || x < 0 || x >= len(v.lines[y]) {
return cell{}, false
}
return v.lines[y][x], true
}
// Write appends a byte slice into the view's internal buffer. Because
// View implements the io.Writer interface, it can be passed as parameter
// of functions like fmt.Fprintf, fmt.Fprintln, io.Copy, etc. Clear must
@ -625,17 +639,29 @@ func (v *View) writeRunes(p []rune) {
for _, r := range p {
switch r {
case '\n':
if c, ok := v.readCell(v.wx+1, v.wy); !ok || c.chr == 0 {
v.writeCells(v.wx, v.wy, []cell{{
chr: 0,
fgColor: 0,
bgColor: 0,
}})
}
v.wx = 0
v.wy++
if v.wy >= len(v.lines) {
v.lines = append(v.lines, nil)
}
fallthrough
// not valid in every OS, but making runtime OS checks in cycle is bad.
case '\r':
if c, ok := v.readCell(v.wx, v.wy); !ok || c.chr == 0 {
v.writeCells(v.wx, v.wy, []cell{{
chr: 0,
fgColor: 0,
bgColor: 0,
}})
}
v.wx = 0
default:
moveCursor, cells := v.parseInput(r)
moveCursor, cells := v.parseInput(r, v.wx, v.wy)
if cells == nil {
continue
}
@ -660,7 +686,7 @@ func (v *View) writeString(s string) {
// parseInput parses char by char the input written to the View. It returns nil
// while processing ESC sequences. Otherwise, it returns a cell slice that
// contains the processed data.
func (v *View) parseInput(ch rune) (bool, []cell) {
func (v *View) parseInput(ch rune, x int, y int) (bool, []cell) {
cells := []cell{}
moveCursor := true
@ -692,8 +718,9 @@ func (v *View) parseInput(ch rune) (bool, []cell) {
return moveCursor, nil
} else if ch == '\t' {
// fill tab-sized space
const tabStop = 4
ch = ' '
repeatCount = 4
repeatCount = tabStop - (x % tabStop)
}
c := cell{
fgColor: v.ei.curFgColor,
@ -923,30 +950,44 @@ func (v *View) draw() error {
start = len(v.viewLines) - 1
}
y := 0
emptyCell := cell{chr: ' ', fgColor: ColorDefault, bgColor: ColorDefault}
var prevFgColor Attribute
for _, vline := range v.viewLines[start:] {
for y, vline := range v.viewLines[start:] {
if y >= maxY {
break
}
x := 0
j := 0
// x tracks the current x position in the view, and cellIdx tracks the
// index of the cell. If we print a double-sized rune, we increment cellIdx
// by one but x by two.
x := -v.ox
cellIdx := 0
var c cell
for {
if j < v.ox {
j++
continue
}
if x >= maxX {
break
}
if j > len(vline.line)-1 {
if x < 0 {
if cellIdx < len(vline.line) {
x += runewidth.RuneWidth(vline.line[cellIdx].chr)
cellIdx++
continue
} else {
// no more characters to write so we're only going to be printing empty cells
// past this point
x = 0
}
}
// if we're out of cells to write, we'll just print empty cells.
if cellIdx > len(vline.line)-1 {
c = emptyCell
c.fgColor = prevFgColor
} else {
c = vline.line[j]
c = vline.line[cellIdx]
// capturing previous foreground colour so that if we're using the reverse
// attribute we honour the final character's colour and don't awkwardly switch
// to a new background colour for the remainder of the line
@ -976,9 +1017,8 @@ func (v *View) draw() error {
// Not sure why the previous code was here but it caused problems
// when typing wide characters in an editor
x += runewidth.RuneWidth(c.chr)
j++
cellIdx++
}
y++
}
return nil
}
@ -1050,7 +1090,7 @@ func (v *View) BufferLines() []string {
lines := make([]string, len(v.lines))
for i, l := range v.lines {
str := lineType(l).String()
str = strings.Replace(str, "\x00", " ", -1)
str = strings.Replace(str, "\x00", "", -1)
lines[i] = str
}
return lines
@ -1068,7 +1108,7 @@ func (v *View) ViewBufferLines() []string {
lines := make([]string, len(v.viewLines))
for i, l := range v.viewLines {
str := lineType(l.line).String()
str = strings.Replace(str, "\x00", " ", -1)
str = strings.Replace(str, "\x00", "", -1)
lines[i] = str
}
return lines
@ -1214,15 +1254,22 @@ func (v *View) GetClickedTabIndex(x int) int {
return 0
}
charIndex := 0
charX := 1
if x <= charX {
return -1
}
for i, tab := range v.Tabs {
charIndex += len(tab + " - ")
if x < charIndex {
charX += runewidth.StringWidth(tab)
if x <= charX {
return i
}
charX += runewidth.StringWidth(" - ")
if x <= charX {
return -1
}
}
return 0
return -1
}
func (v *View) SelectedLineIdx() int {
@ -1230,6 +1277,16 @@ func (v *View) SelectedLineIdx() int {
return seletedLineIdx
}
// expected to only be used in tests
func (v *View) SelectedLine() string {
if len(v.lines) == 0 {
return ""
}
line := v.lines[v.SelectedLineIdx()]
str := lineType(line).String()
return strings.Replace(str, "\x00", "", -1)
}
func (v *View) SelectedPoint() (int, int) {
cx, cy := v.Cursor()
ox, oy := v.Origin()

@ -0,0 +1,31 @@
package utils
import (
"io"
"sync"
)
// This wraps a writer and ensures that before we actually write anything we call a given function first
type OnceWriter struct {
writer io.Writer
once sync.Once
f func()
}
var _ io.Writer = &OnceWriter{}
func NewOnceWriter(writer io.Writer, f func()) *OnceWriter {
return &OnceWriter{
writer: writer,
f: f,
}
}
func (self *OnceWriter) Write(p []byte) (n int, err error) {
self.once.Do(func() {
self.f()
})
return self.writer.Write(p)
}

@ -22,6 +22,16 @@ func Max(x, y int) int {
return y
}
// Clamp returns a value x restricted between min and max
func Clamp(x int, min int, max int) int {
if x < min {
return min
} else if x > max {
return max
}
return x
}
// GetLazyRootDirectory finds a lazy project root directory.
//
// It's used for cheatsheet scripts and integration tests. Not to be confused with finding the

@ -1,16 +0,0 @@
language: go
sudo: false
go:
- 1.13.x
- tip
before_install:
- go get -t -v ./...
script:
- go generate
- git diff --cached --exit-code
- ./go.test.sh
after_success:
- bash <(curl -s https://codecov.io/bash)

@ -1,7 +1,7 @@
go-runewidth
============
[![Build Status](https://travis-ci.org/mattn/go-runewidth.png?branch=master)](https://travis-ci.org/mattn/go-runewidth)
[![Build Status](https://github.com/mattn/go-runewidth/workflows/test/badge.svg?branch=master)](https://github.com/mattn/go-runewidth/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/mattn/go-runewidth/branch/master/graph/badge.svg)](https://codecov.io/gh/mattn/go-runewidth)
[![GoDoc](https://godoc.org/github.com/mattn/go-runewidth?status.svg)](http://godoc.org/github.com/mattn/go-runewidth)
[![Go Report Card](https://goreportcard.com/badge/github.com/mattn/go-runewidth)](https://goreportcard.com/report/github.com/mattn/go-runewidth)

@ -2,6 +2,7 @@ package runewidth
import (
"os"
"strings"
"github.com/rivo/uniseg"
)
@ -34,7 +35,13 @@ func handleEnv() {
EastAsianWidth = env == "1"
}
// update DefaultCondition
DefaultCondition.EastAsianWidth = EastAsianWidth
if DefaultCondition.EastAsianWidth != EastAsianWidth {
DefaultCondition.EastAsianWidth = EastAsianWidth
if len(DefaultCondition.combinedLut) > 0 {
DefaultCondition.combinedLut = DefaultCondition.combinedLut[:0]
CreateLUT()
}
}
}
type interval struct {
@ -89,6 +96,7 @@ var nonprint = table{
// Condition have flag EastAsianWidth whether the current locale is CJK or not.
type Condition struct {
combinedLut []byte
EastAsianWidth bool
StrictEmojiNeutral bool
}
@ -104,10 +112,16 @@ func NewCondition() *Condition {
// RuneWidth returns the number of cells in r.
// See http://www.unicode.org/reports/tr11/
func (c *Condition) RuneWidth(r rune) int {
if r < 0 || r > 0x10FFFF {
return 0
}
if len(c.combinedLut) > 0 {
return int(c.combinedLut[r>>1]>>(uint(r&1)*4)) & 3
}
// optimized version, verified by TestRuneWidthChecksums()
if !c.EastAsianWidth {
switch {
case r < 0x20 || r > 0x10FFFF:
case r < 0x20:
return 0
case (r >= 0x7F && r <= 0x9F) || r == 0xAD: // nonprint
return 0
@ -124,7 +138,7 @@ func (c *Condition) RuneWidth(r rune) int {
}
} else {
switch {
case r < 0 || r > 0x10FFFF || inTables(r, nonprint, combining):
case inTables(r, nonprint, combining):
return 0
case inTable(r, narrow):
return 1
@ -138,6 +152,27 @@ func (c *Condition) RuneWidth(r rune) int {
}
}
// CreateLUT will create an in-memory lookup table of 557056 bytes for faster operation.
// This should not be called concurrently with other operations on c.
// If options in c is changed, CreateLUT should be called again.
func (c *Condition) CreateLUT() {
const max = 0x110000
lut := c.combinedLut
if len(c.combinedLut) != 0 {
// Remove so we don't use it.
c.combinedLut = nil
} else {
lut = make([]byte, max/2)
}
for i := range lut {
i32 := int32(i * 2)
x0 := c.RuneWidth(i32)
x1 := c.RuneWidth(i32 + 1)
lut[i] = uint8(x0) | uint8(x1)<<4
}
c.combinedLut = lut
}
// StringWidth return width as you can see
func (c *Condition) StringWidth(s string) (width int) {
g := uniseg.NewGraphemes(s)
@ -180,11 +215,47 @@ func (c *Condition) Truncate(s string, w int, tail string) string {
return s[:pos] + tail
}
// TruncateLeft cuts w cells from the beginning of the `s`.
func (c *Condition) TruncateLeft(s string, w int, prefix string) string {
if c.StringWidth(s) <= w {
return prefix
}
var width int
pos := len(s)
g := uniseg.NewGraphemes(s)
for g.Next() {
var chWidth int
for _, r := range g.Runes() {
chWidth = c.RuneWidth(r)
if chWidth > 0 {
break // See StringWidth() for details.
}
}
if width+chWidth > w {
if width < w {
_, pos = g.Positions()
prefix += strings.Repeat(" ", width+chWidth-w)
} else {
pos, _ = g.Positions()
}
break
}
width += chWidth
}
return prefix + s[pos:]
}
// Wrap return string wrapped with w cells
func (c *Condition) Wrap(s string, w int) string {
width := 0
out := ""
for _, r := range []rune(s) {
for _, r := range s {
cw := c.RuneWidth(r)
if r == '\n' {
out += string(r)
@ -257,6 +328,11 @@ func Truncate(s string, w int, tail string) string {
return DefaultCondition.Truncate(s, w, tail)
}
// TruncateLeft cuts w cells from the beginning of the `s`.
func TruncateLeft(s string, w int, prefix string) string {
return DefaultCondition.TruncateLeft(s, w, prefix)
}
// Wrap return string wrapped with w cells
func Wrap(s string, w int) string {
return DefaultCondition.Wrap(s, w)
@ -271,3 +347,12 @@ func FillLeft(s string, w int) string {
func FillRight(s string, w int) string {
return DefaultCondition.FillRight(s, w)
}
// CreateLUT will create an in-memory lookup table of 557055 bytes for faster operation.
// This should not be called concurrently with other operations.
func CreateLUT() {
if len(DefaultCondition.combinedLut) > 0 {
return
}
DefaultCondition.CreateLUT()
}

@ -1,3 +1,4 @@
//go:build appengine
// +build appengine
package runewidth

@ -1,5 +1,5 @@
// +build js
// +build !appengine
//go:build js && !appengine
// +build js,!appengine
package runewidth

@ -1,6 +1,5 @@
// +build !windows
// +build !js
// +build !appengine
//go:build !windows && !js && !appengine
// +build !windows,!js,!appengine
package runewidth

@ -1,5 +1,5 @@
// +build windows
// +build !appengine
//go:build windows && !appengine
// +build windows,!appengine
package runewidth

@ -0,0 +1,4 @@
*~
*.test
.*.swp
.DS_Store

@ -0,0 +1,11 @@
language: go
go:
- 1.3.x
- 1.4.x
- 1.5.x
- 1.6.x
- 1.7.x
- 1.8.x
- 1.9.x
- master

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

@ -0,0 +1,5 @@
# goid [![Build Status](https://travis-ci.org/petermattis/goid.svg?branch=master)](https://travis-ci.org/petermattis/goid)
Programatically retrieve the current goroutine's ID. See [the CI
configuration](.travis.yml) for supported Go versions. In addition,
gccgo 7.2.1 (Go 1.8.3) is supported.

@ -0,0 +1,35 @@
// Copyright 2016 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
package goid
import (
"bytes"
"runtime"
"strconv"
)
func ExtractGID(s []byte) int64 {
s = s[len("goroutine "):]
s = s[:bytes.IndexByte(s, ' ')]
gid, _ := strconv.ParseInt(string(s), 10, 64)
return gid
}
// Parse the goid from runtime.Stack() output. Slow, but it works.
func getSlow() int64 {
var buf [64]byte
return ExtractGID(buf[:runtime.Stack(buf[:], false)])
}

@ -0,0 +1,25 @@
// Copyright 2018 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// +build gccgo
package goid
//extern runtime.getg
func getg() *g
func Get() int64 {
return getg().goid
}

@ -0,0 +1,23 @@
// Copyright 2015 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// +build !go1.4
#include <runtime.h>
void ·Get(int64 ret) {
ret = g->goid;
USED(&ret);
}

@ -0,0 +1,21 @@
// Copyright 2015 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// +build !go1.4
package goid
// Get returns the id of the current goroutine.
func Get() int64

@ -0,0 +1,34 @@
// Copyright 2015 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// +build go1.4,!go1.5
package goid
import "unsafe"
var pointerSize = unsafe.Sizeof(uintptr(0))
// Backdoor access to runtime·getg().
func getg() uintptr // in goid_go1.4.s
// Get returns the id of the current goroutine.
func Get() int64 {
// The goid is the 16th field in the G struct where each field is a
// pointer, uintptr or padded to that size. See runtime.h from the
// Go sources. I'm not aware of a cleaner way to determine the
// offset.
return *(*int64)(unsafe.Pointer(getg() + 16*pointerSize))
}

@ -0,0 +1,18 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Assembly to get into package runtime without using exported symbols.
// See https://github.com/golang/go/blob/release-branch.go1.4/misc/cgo/test/backdoor/thunk.s
// +build amd64 amd64p32 arm 386
// +build go1.4,!go1.5
#include "textflag.h"
#ifdef GOARCH_arm
#define JMP B
#endif
TEXT ·getg(SB),NOSPLIT,$0-0
JMP runtime·getg(SB)

@ -0,0 +1,21 @@
// Copyright 2016 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// +build amd64 amd64p32
// +build gc,go1.5
package goid
func Get() int64

@ -0,0 +1,29 @@
// Copyright 2016 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// Assembly to mimic runtime.getg.
// +build amd64 amd64p32
// +build gc,go1.5
#include "go_asm.h"
#include "textflag.h"
// func Get() int64
TEXT ·Get(SB),NOSPLIT,$0-8
MOVQ (TLS), R14
MOVQ g_goid(R14), R13
MOVQ R13, ret+0(FP)
RET

@ -0,0 +1,26 @@
// Copyright 2016 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// +build arm
// +build gc,go1.5
package goid
// Backdoor access to runtime·getg().
func getg() *g // in goid_go1.5plus.s
func Get() int64 {
return getg().goid
}

@ -0,0 +1,27 @@
// Copyright 2016 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// Assembly to mimic runtime.getg.
// This should work on arm64 as well, but it hasn't been tested.
// +build arm
// +build gc,go1.5
#include "textflag.h"
// func getg() *g
TEXT ·getg(SB),NOSPLIT,$0-8
MOVW g, ret+0(FP)
RET

@ -0,0 +1,23 @@
// Copyright 2016 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// +build go1.4,!go1.5,!amd64,!amd64p32,!arm,!386 go1.5,!go1.6,!amd64,!amd64p32,!arm go1.6,!amd64,!amd64p32,!arm go1.9,!amd64,!amd64p32,!arm
package goid
// Get returns the id of the current goroutine.
func Get() int64 {
return getSlow()
}

@ -0,0 +1,16 @@
// +build gccgo,go1.8
package goid
// https://github.com/gcc-mirror/gcc/blob/gcc-7-branch/libgo/go/runtime/runtime2.go#L329-L422
type g struct {
_panic uintptr
_defer uintptr
m uintptr
syscallsp uintptr
syscallpc uintptr
param uintptr
atomicstatus uint32
goid int64 // Here it is!
}

@ -0,0 +1,56 @@
// Copyright 2016 Peter Mattis.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License. See the AUTHORS file
// for names of contributors.
// +build go1.5,!go1.6
package goid
// Just enough of the structs from runtime/runtime2.go to get the offset to goid.
// See https://github.com/golang/go/blob/release-branch.go1.5/src/runtime/runtime2.go
type stack struct {
lo uintptr
hi uintptr
}
type gobuf struct {
sp uintptr
pc uintptr
g uintptr
ctxt uintptr
ret uintptr
lr uintptr
bp uintptr
}
type g struct {
stack stack
stackguard0 uintptr
stackguard1 uintptr
_panic uintptr
_defer uintptr
m uintptr
stackAlloc uintptr
sched gobuf
syscallsp uintptr
syscallpc uintptr
stkbar []uintptr
stkbarPos uintptr
param uintptr
atomicstatus uint32
stackLock uint32
goid int64 // Here it is!
}

@ -0,0 +1,42 @@
// +build gc,go1.6,!go1.9
package goid
// Just enough of the structs from runtime/runtime2.go to get the offset to goid.
// See https://github.com/golang/go/blob/release-branch.go1.6/src/runtime/runtime2.go
type stack struct {
lo uintptr
hi uintptr
}
type gobuf struct {
sp uintptr
pc uintptr
g uintptr
ctxt uintptr
ret uintptr
lr uintptr
bp uintptr
}
type g struct {
stack stack
stackguard0 uintptr
stackguard1 uintptr
_panic uintptr
_defer uintptr
m uintptr
stackAlloc uintptr
sched gobuf
syscallsp uintptr
syscallpc uintptr
stkbar []uintptr
stkbarPos uintptr
stktopsp uintptr
param uintptr
atomicstatus uint32
stackLock uint32
goid int64 // Here it is!
}

@ -0,0 +1,36 @@
// +build gc,go1.9
package goid
type stack struct {
lo uintptr
hi uintptr
}
type gobuf struct {
sp uintptr
pc uintptr
g uintptr
ctxt uintptr
ret uintptr
lr uintptr
bp uintptr
}
type g struct {
stack stack
stackguard0 uintptr
stackguard1 uintptr
_panic uintptr
_defer uintptr
m uintptr
sched gobuf
syscallsp uintptr
syscallpc uintptr
stktopsp uintptr
param uintptr
atomicstatus uint32
stackLock uint32
goid int64 // Here it is!
}

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

Loading…
Cancel
Save