mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-25 08:31:03 +00:00
Merge 3551c33c10 into 7e7aadc207
This commit is contained in:
commit
22397be89a
22 changed files with 1101 additions and 1 deletions
10
README.md
10
README.md
|
|
@ -267,6 +267,16 @@ echo "alias lzd='lazydocker'" >> ~/.zshrc
|
||||||
- List of keybindings
|
- List of keybindings
|
||||||
[here](/docs/keybindings).
|
[here](/docs/keybindings).
|
||||||
|
|
||||||
|
### Search and pull images
|
||||||
|
|
||||||
|
Focus the **Images** panel and press <kbd>Shift</kbd>+<kbd>I</kbd> to search Docker Hub repositories as you type. Use <kbd>↑</kbd>/<kbd>↓</kbd> to select a repository and press <kbd>Enter</kbd> to browse its tags.
|
||||||
|
|
||||||
|
<!-- Screenshot placeholder: live Docker Hub repository search -->
|
||||||
|
|
||||||
|
The tag search shows the exact image reference, Docker Hub size, last-pushed time, supported platforms, digest, and media type. Press <kbd>Enter</kbd> on a tag to pull it and refresh the Images panel.
|
||||||
|
|
||||||
|
<!-- Screenshot placeholder: live Docker Hub tag search and image metadata -->
|
||||||
|
|
||||||
## Cool features
|
## Cool features
|
||||||
|
|
||||||
everything is one keypress away (or one click away! Mouse support FTW):
|
everything is one keypress away (or one click away! Mouse support FTW):
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
## Images
|
## Images
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
<kbd>I</kbd>: search and pull image
|
||||||
<kbd>c</kbd>: führe vordefinierten benutzerdefinierten Befehl aus
|
<kbd>c</kbd>: führe vordefinierten benutzerdefinierten Befehl aus
|
||||||
<kbd>d</kbd>: entferne Image
|
<kbd>d</kbd>: entferne Image
|
||||||
<kbd>b</kbd>: view bulk commands
|
<kbd>b</kbd>: view bulk commands
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
## Images
|
## Images
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
<kbd>I</kbd>: search and pull image
|
||||||
<kbd>c</kbd>: run predefined custom command
|
<kbd>c</kbd>: run predefined custom command
|
||||||
<kbd>d</kbd>: remove image
|
<kbd>d</kbd>: remove image
|
||||||
<kbd>b</kbd>: view bulk commands
|
<kbd>b</kbd>: view bulk commands
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
## Imágenes
|
## Imágenes
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
<kbd>I</kbd>: search and pull image
|
||||||
<kbd>c</kbd>: ejecutar comando personalizado
|
<kbd>c</kbd>: ejecutar comando personalizado
|
||||||
<kbd>d</kbd>: limpiar imagen
|
<kbd>d</kbd>: limpiar imagen
|
||||||
<kbd>b</kbd>: ver comandos masivos
|
<kbd>b</kbd>: ver comandos masivos
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
## Images
|
## Images
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
<kbd>I</kbd>: search and pull image
|
||||||
<kbd>c</kbd>: exécuter une commande prédéfinie
|
<kbd>c</kbd>: exécuter une commande prédéfinie
|
||||||
<kbd>d</kbd>: supprimer l'image
|
<kbd>d</kbd>: supprimer l'image
|
||||||
<kbd>b</kbd>: voir les commandes groupées
|
<kbd>b</kbd>: voir les commandes groupées
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
## Images
|
## Images
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
<kbd>I</kbd>: search and pull image
|
||||||
<kbd>c</kbd>: draai een vooraf bedacht aangepaste opdracht
|
<kbd>c</kbd>: draai een vooraf bedacht aangepaste opdracht
|
||||||
<kbd>d</kbd>: verwijder image
|
<kbd>d</kbd>: verwijder image
|
||||||
<kbd>b</kbd>: view bulk commands
|
<kbd>b</kbd>: view bulk commands
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
## Obrazy
|
## Obrazy
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
<kbd>I</kbd>: search and pull image
|
||||||
<kbd>c</kbd>: wykonaj predefiniowaną własną komende
|
<kbd>c</kbd>: wykonaj predefiniowaną własną komende
|
||||||
<kbd>d</kbd>: usuń obraz
|
<kbd>d</kbd>: usuń obraz
|
||||||
<kbd>b</kbd>: view bulk commands
|
<kbd>b</kbd>: view bulk commands
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
## Imagens
|
## Imagens
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
<kbd>I</kbd>: search and pull image
|
||||||
<kbd>c</kbd>: executar comando personalizado predefinido
|
<kbd>c</kbd>: executar comando personalizado predefinido
|
||||||
<kbd>d</kbd>: remover imagem
|
<kbd>d</kbd>: remover imagem
|
||||||
<kbd>b</kbd>: ver comandos em massa
|
<kbd>b</kbd>: ver comandos em massa
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
## Imajlar
|
## Imajlar
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
<kbd>I</kbd>: search and pull image
|
||||||
<kbd>c</kbd>: önceden tanımlanmış özel komutu çalıştır
|
<kbd>c</kbd>: önceden tanımlanmış özel komutu çalıştır
|
||||||
<kbd>d</kbd>: imajı kaldır
|
<kbd>d</kbd>: imajı kaldır
|
||||||
<kbd>b</kbd>: view bulk commands
|
<kbd>b</kbd>: view bulk commands
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
## 镜像
|
## 镜像
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
<kbd>I</kbd>: search and pull image
|
||||||
<kbd>c</kbd>: 运行预定义的自定义命令
|
<kbd>c</kbd>: 运行预定义的自定义命令
|
||||||
<kbd>d</kbd>: 移除镜像
|
<kbd>d</kbd>: 移除镜像
|
||||||
<kbd>b</kbd>: 查看批量命令
|
<kbd>b</kbd>: 查看批量命令
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@ package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
cliconfig "github.com/docker/cli/cli/config"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/jesseduffield/lazydocker/pkg/utils"
|
"github.com/jesseduffield/lazydocker/pkg/utils"
|
||||||
|
|
@ -13,6 +18,11 @@ import (
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dockerHubRegistryAddress = "https://index.docker.io/v1/"
|
||||||
|
imageSearchResultLimit = 50
|
||||||
|
)
|
||||||
|
|
||||||
// Image : A docker Image
|
// Image : A docker Image
|
||||||
type Image struct {
|
type Image struct {
|
||||||
Name string
|
Name string
|
||||||
|
|
@ -136,6 +146,78 @@ func (c *DockerCommand) RefreshImages() ([]*Image, error) {
|
||||||
return ownImages, nil
|
return ownImages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchImages searches Docker Hub for images matching the supplied term.
|
||||||
|
func (c *DockerCommand) SearchImages(ctx context.Context, term string) ([]registry.SearchResult, error) {
|
||||||
|
registryAuth, err := dockerHubRegistryAuth()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Client.ImageSearch(ctx, term, registry.SearchOptions{
|
||||||
|
Limit: imageSearchResultLimit,
|
||||||
|
RegistryAuth: registryAuth,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullImage pulls an image into the local Docker daemon and consumes the full
|
||||||
|
// response stream so that errors reported by the registry are not missed.
|
||||||
|
func (c *DockerCommand) PullImage(reference string) error {
|
||||||
|
registryAuth, err := dockerHubRegistryAuth()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := c.Client.ImagePull(context.Background(), reference, image.PullOptions{
|
||||||
|
RegistryAuth: registryAuth,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
return consumeImagePullResponse(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerHubRegistryAuth() (string, error) {
|
||||||
|
configFile, err := cliconfig.Load(cliconfig.Dir())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
authConfig, err := configFile.GetAuthConfig(dockerHubRegistryAddress)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry.EncodeAuthConfig(registry.AuthConfig(authConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeImagePullResponse(reader io.Reader) error {
|
||||||
|
decoder := json.NewDecoder(reader)
|
||||||
|
for {
|
||||||
|
var message struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDetail struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errorDetail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := decoder.Decode(&message); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.ErrorDetail.Message != "" {
|
||||||
|
return errors.New(message.ErrorDetail.Message)
|
||||||
|
}
|
||||||
|
if message.Error != "" {
|
||||||
|
return errors.New(message.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PruneImages prunes images
|
// PruneImages prunes images
|
||||||
func (c *DockerCommand) PruneImages() error {
|
func (c *DockerCommand) PruneImages() error {
|
||||||
_, err := c.Client.ImagesPrune(context.Background(), filters.Args{})
|
_, err := c.Client.ImagesPrune(context.Background(), filters.Args{})
|
||||||
|
|
|
||||||
96
pkg/commands/image_registry.go
Normal file
96
pkg/commands/image_registry.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dockerHubAPIBaseURL = "https://hub.docker.com"
|
||||||
|
|
||||||
|
var dockerHubHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||||
|
|
||||||
|
// DockerHubTag contains the metadata Docker Hub returns for a repository tag.
|
||||||
|
type DockerHubTag struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullSize int64 `json:"full_size"`
|
||||||
|
LastUpdated string `json:"last_updated"`
|
||||||
|
TagLastPushed string `json:"tag_last_pushed"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Images []DockerHubTagVariant `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DockerHubTagVariant describes one platform-specific image behind a tag.
|
||||||
|
type DockerHubTagVariant struct {
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
LastPushed string `json:"last_pushed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchImageTags searches Docker Hub tags for an image repository.
|
||||||
|
func (c *DockerCommand) SearchImageTags(ctx context.Context, imageName, query string) ([]DockerHubTag, error) {
|
||||||
|
return searchImageTags(ctx, dockerHubHTTPClient, dockerHubAPIBaseURL, imageName, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchImageTags(ctx context.Context, httpClient *http.Client, baseURL, imageName, query string) ([]DockerHubTag, error) {
|
||||||
|
namespace, repository, err := dockerHubRepositoryParts(imageName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := url.Parse(strings.TrimRight(baseURL, "/"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build Docker Hub URL: %w", err)
|
||||||
|
}
|
||||||
|
endpoint.Path = fmt.Sprintf("/v2/namespaces/%s/repositories/%s/tags", url.PathEscape(namespace), url.PathEscape(repository))
|
||||||
|
values := endpoint.Query()
|
||||||
|
values.Set("page_size", "50")
|
||||||
|
if query = strings.TrimSpace(query); query != "" {
|
||||||
|
values.Set("name", query)
|
||||||
|
}
|
||||||
|
endpoint.RawQuery = values.Encode()
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build Docker Hub tag request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
response, err := httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search Docker Hub tags: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("search Docker Hub tags: %s", response.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Results []DockerHubTag `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse Docker Hub tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.Results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerHubRepositoryParts(imageName string) (string, string, error) {
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(imageName), "/", 2)
|
||||||
|
if len(parts) == 1 && parts[0] != "" {
|
||||||
|
return "library", parts[0], nil
|
||||||
|
}
|
||||||
|
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
|
||||||
|
return parts[0], parts[1], nil
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("invalid Docker Hub image name %q", imageName)
|
||||||
|
}
|
||||||
43
pkg/commands/image_registry_test.go
Normal file
43
pkg/commands/image_registry_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearchImageTagsUsesOfficialNamespaceAndQuery(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/v2/namespaces/library/repositories/nginx/tags", r.URL.Path)
|
||||||
|
assert.Equal(t, "alpine", r.URL.Query().Get("name"))
|
||||||
|
assert.Equal(t, "50", r.URL.Query().Get("page_size"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"results":[{"name":"alpine","full_size":75270706,"digest":"sha256:abc","images":[{"architecture":"amd64","os":"linux","size":75270706}]}]}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tags, err := searchImageTags(context.Background(), server.Client(), server.URL, "nginx", " alpine ")
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if assert.Len(t, tags, 1) {
|
||||||
|
assert.Equal(t, "alpine", tags[0].Name)
|
||||||
|
assert.EqualValues(t, 75270706, tags[0].FullSize)
|
||||||
|
assert.Equal(t, "amd64", tags[0].Images[0].Architecture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchImageTagsUsesRepositoryNamespace(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/v2/namespaces/bitnami/repositories/nginx/tags", r.URL.Path)
|
||||||
|
fmt.Fprint(w, `{"results":[]}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
_, err := searchImageTags(context.Background(), server.Client(), server.URL, "bitnami/nginx", "")
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
76
pkg/commands/image_test.go
Normal file
76
pkg/commands/image_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDockerCommand(t *testing.T, handler http.HandlerFunc) *DockerCommand {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
dockerClient, err := client.NewClientWithOpts(
|
||||||
|
client.WithHost(server.URL),
|
||||||
|
client.WithHTTPClient(server.Client()),
|
||||||
|
client.WithVersion("1.47"),
|
||||||
|
)
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = dockerClient.Close() })
|
||||||
|
|
||||||
|
return &DockerCommand{Client: dockerClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchImages(t *testing.T) {
|
||||||
|
command := newTestDockerCommand(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/v1.47/images/search", r.URL.Path)
|
||||||
|
assert.Equal(t, "postgres", r.URL.Query().Get("term"))
|
||||||
|
assert.Equal(t, "50", r.URL.Query().Get("limit"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `[{"name":"postgres","description":"PostgreSQL","star_count":14000,"is_official":true}]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
results, err := command.SearchImages(context.Background(), "postgres")
|
||||||
|
|
||||||
|
if !assert.NoError(t, err) || !assert.Len(t, results, 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, "postgres", results[0].Name)
|
||||||
|
assert.True(t, results[0].IsOfficial)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullImage(t *testing.T) {
|
||||||
|
command := newTestDockerCommand(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/v1.47/images/create", r.URL.Path)
|
||||||
|
assert.Equal(t, "docker.io/library/postgres", r.URL.Query().Get("fromImage"))
|
||||||
|
assert.Equal(t, "16", r.URL.Query().Get("tag"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintln(w, `{"status":"Pulling from library/postgres"}`)
|
||||||
|
fmt.Fprintln(w, `{"status":"Downloaded newer image"}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, command.PullImage("postgres:16"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullImageReturnsStreamError(t *testing.T) {
|
||||||
|
command := newTestDockerCommand(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintln(w, `{"errorDetail":{"message":"manifest unknown"},"error":"manifest unknown"}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := command.PullImage("postgres:missing")
|
||||||
|
|
||||||
|
if !assert.Error(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, "manifest unknown", err.Error())
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,8 @@ func (gui *Gui) newLineFocused(v *gocui.View) error {
|
||||||
switch v.Name() {
|
switch v.Name() {
|
||||||
case "confirmation":
|
case "confirmation":
|
||||||
return nil
|
return nil
|
||||||
|
case imageSearchInputViewName:
|
||||||
|
return nil
|
||||||
case "main":
|
case "main":
|
||||||
v.Highlight = false
|
v.Highlight = false
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ type guiState struct {
|
||||||
// Maintains the state of manual filtering i.e. typing in a substring
|
// Maintains the state of manual filtering i.e. typing in a substring
|
||||||
// to filter on in the current panel.
|
// to filter on in the current panel.
|
||||||
Filter filterState
|
Filter filterState
|
||||||
|
|
||||||
|
ImageSearch *imageSearchState
|
||||||
}
|
}
|
||||||
|
|
||||||
type filterState struct {
|
type filterState struct {
|
||||||
|
|
@ -137,6 +139,7 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand
|
||||||
|
|
||||||
ShowExitedContainers: true,
|
ShowExitedContainers: true,
|
||||||
ScreenMode: getScreenMode(config),
|
ScreenMode: getScreenMode(config),
|
||||||
|
ImageSearch: &imageSearchState{},
|
||||||
}
|
}
|
||||||
|
|
||||||
gui := &Gui{
|
gui := &Gui{
|
||||||
|
|
|
||||||
603
pkg/gui/image_search_panel.go
Normal file
603
pkg/gui/image_search_panel.go
Normal file
|
|
@ -0,0 +1,603 @@
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/jesseduffield/gocui"
|
||||||
|
"github.com/jesseduffield/lazydocker/pkg/commands"
|
||||||
|
"github.com/jesseduffield/lazydocker/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type imageSearchMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
imageSearchModeRepositories imageSearchMode = iota
|
||||||
|
imageSearchModeTags
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
imageSearchResultsViewName = "imageSearchResults"
|
||||||
|
imageSearchInputViewName = "imageSearchInput"
|
||||||
|
imageSearchDetailsViewName = "imageSearchDetails"
|
||||||
|
imageSearchDebounce = 250 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
type imageSearchState struct {
|
||||||
|
Mode imageSearchMode
|
||||||
|
Active bool
|
||||||
|
Query string
|
||||||
|
Results []registry.SearchResult
|
||||||
|
Tags []commands.DockerHubTag
|
||||||
|
SelectedImage registry.SearchResult
|
||||||
|
SelectedLine int
|
||||||
|
RequestID uint64
|
||||||
|
Searching bool
|
||||||
|
SearchError string
|
||||||
|
WorkerRunning bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) handleSearchImages(g *gocui.Gui, v *gocui.View) error {
|
||||||
|
gui.onNewPopupPanel()
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
state.mu.Lock()
|
||||||
|
state.Mode = imageSearchModeRepositories
|
||||||
|
state.Active = true
|
||||||
|
state.Query = ""
|
||||||
|
state.Results = nil
|
||||||
|
state.Tags = nil
|
||||||
|
state.SelectedImage = registry.SearchResult{}
|
||||||
|
state.SelectedLine = 0
|
||||||
|
state.RequestID++
|
||||||
|
state.Searching = false
|
||||||
|
state.SearchError = ""
|
||||||
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
gui.g.Update(func(g *gocui.Gui) error {
|
||||||
|
if err := gui.layoutImageSearchPanel(g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
inputView, err := g.View(imageSearchInputViewName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
inputView.ClearTextArea()
|
||||||
|
_ = inputView.SetCursor(0, 0)
|
||||||
|
_ = inputView.SetOrigin(0, 0)
|
||||||
|
inputView.Editor = gocui.EditorFunc(func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
|
||||||
|
matched := gocui.DefaultEditor.Edit(v, key, ch, mod)
|
||||||
|
if matched {
|
||||||
|
gui.handleImageSearchQueryChange(v.Buffer())
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
})
|
||||||
|
|
||||||
|
gui.bindImageSearchKeys(g)
|
||||||
|
gui.renderImageSearchPanel(g)
|
||||||
|
return gui.switchFocus(inputView)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) layoutImageSearchPanel(g *gocui.Gui) error {
|
||||||
|
width, height := g.Size()
|
||||||
|
x0 := width / 14
|
||||||
|
x1 := width - x0 - 1
|
||||||
|
y0 := height / 16
|
||||||
|
y1 := height - y0 - 3
|
||||||
|
|
||||||
|
if height < 15 {
|
||||||
|
y0 = 0
|
||||||
|
y1 = height - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsBottom := y0 + (y1-y0)/3
|
||||||
|
if resultsBottom < y0+3 {
|
||||||
|
resultsBottom = y0 + 3
|
||||||
|
}
|
||||||
|
inputTop := resultsBottom + 1
|
||||||
|
inputBottom := inputTop + 2
|
||||||
|
detailsTop := inputBottom + 1
|
||||||
|
detailsBottom := y1
|
||||||
|
if detailsTop >= y1 {
|
||||||
|
detailsTop = height + 1000
|
||||||
|
detailsBottom = detailsTop + 2
|
||||||
|
}
|
||||||
|
inputTitle, detailsTitle := gui.imageSearchPanelTitles()
|
||||||
|
|
||||||
|
resultsView, err := g.SetView(imageSearchResultsViewName, x0, y0, x1, resultsBottom, 0)
|
||||||
|
if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resultsView.FgColor = gocui.ColorDefault
|
||||||
|
resultsView.Wrap = false
|
||||||
|
|
||||||
|
inputView, err := g.SetView(imageSearchInputViewName, x0, inputTop, x1, inputBottom, 0)
|
||||||
|
if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
inputView.Title = inputTitle
|
||||||
|
inputView.FgColor = gocui.ColorDefault
|
||||||
|
inputView.Editable = true
|
||||||
|
inputView.Wrap = false
|
||||||
|
|
||||||
|
detailsView, err := g.SetView(imageSearchDetailsViewName, x0, detailsTop, x1, detailsBottom, 0)
|
||||||
|
if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
detailsView.Title = detailsTitle
|
||||||
|
detailsView.FgColor = gocui.ColorDefault
|
||||||
|
detailsView.Wrap = true
|
||||||
|
|
||||||
|
_, _ = g.SetViewOnTop(imageSearchResultsViewName)
|
||||||
|
_, _ = g.SetViewOnTop(imageSearchDetailsViewName)
|
||||||
|
_, _ = g.SetViewOnTop(imageSearchInputViewName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) bindImageSearchKeys(g *gocui.Gui) {
|
||||||
|
g.DeleteViewKeybindings(imageSearchInputViewName)
|
||||||
|
bindings := []struct {
|
||||||
|
key interface{}
|
||||||
|
handler func(*gocui.Gui, *gocui.View) error
|
||||||
|
}{
|
||||||
|
{gocui.KeyArrowUp, gui.handleImageSearchPrevious},
|
||||||
|
{gocui.KeyArrowDown, gui.handleImageSearchNext},
|
||||||
|
{gocui.KeyCtrlP, gui.handleImageSearchPrevious},
|
||||||
|
{gocui.KeyCtrlN, gui.handleImageSearchNext},
|
||||||
|
{gocui.KeyEnter, gui.handleImageSearchSelect},
|
||||||
|
{gocui.KeyEsc, gui.closeImageSearchPanel},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, binding := range bindings {
|
||||||
|
_ = g.SetKeybinding(imageSearchInputViewName, binding.key, gocui.ModNone, binding.handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) handleImageSearchQueryChange(rawQuery string) {
|
||||||
|
query := strings.TrimSpace(rawQuery)
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
state.mu.Lock()
|
||||||
|
if !state.Active || query == state.Query {
|
||||||
|
state.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Query = query
|
||||||
|
state.RequestID++
|
||||||
|
state.SelectedLine = 0
|
||||||
|
state.Results = nil
|
||||||
|
state.Tags = nil
|
||||||
|
state.SearchError = ""
|
||||||
|
shouldSearch := query != "" || state.Mode == imageSearchModeTags
|
||||||
|
state.Searching = shouldSearch
|
||||||
|
startWorker := shouldSearch && !state.WorkerRunning
|
||||||
|
if startWorker {
|
||||||
|
state.WorkerRunning = true
|
||||||
|
}
|
||||||
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
gui.renderImageSearchPanel(gui.g)
|
||||||
|
if startWorker {
|
||||||
|
go gui.runImageSearchWorker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) runImageSearchWorker() {
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
for {
|
||||||
|
time.Sleep(imageSearchDebounce)
|
||||||
|
|
||||||
|
state.mu.Lock()
|
||||||
|
if !state.Active || (state.Mode == imageSearchModeRepositories && state.Query == "") {
|
||||||
|
state.WorkerRunning = false
|
||||||
|
state.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := state.Query
|
||||||
|
requestID := state.RequestID
|
||||||
|
mode := state.Mode
|
||||||
|
imageName := state.SelectedImage.Name
|
||||||
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
var (
|
||||||
|
results []registry.SearchResult
|
||||||
|
tags []commands.DockerHubTag
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if mode == imageSearchModeTags {
|
||||||
|
tags, err = gui.DockerCommand.SearchImageTags(context.Background(), imageName, query)
|
||||||
|
} else {
|
||||||
|
results, err = gui.DockerCommand.SearchImages(context.Background(), query)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.mu.Lock()
|
||||||
|
if !state.Active {
|
||||||
|
state.WorkerRunning = false
|
||||||
|
state.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.RequestID != requestID {
|
||||||
|
state.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Searching = false
|
||||||
|
state.WorkerRunning = false
|
||||||
|
if err != nil {
|
||||||
|
state.SearchError = truncateImageSearchText(imageSearchOneLine(err.Error()), 160)
|
||||||
|
state.Results = nil
|
||||||
|
state.Tags = nil
|
||||||
|
} else {
|
||||||
|
state.SearchError = ""
|
||||||
|
if mode == imageSearchModeTags {
|
||||||
|
state.Tags = tags
|
||||||
|
} else {
|
||||||
|
state.Results = results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
gui.g.Update(func(g *gocui.Gui) error {
|
||||||
|
gui.renderImageSearchPanel(g)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) handleImageSearchPrevious(g *gocui.Gui, v *gocui.View) error {
|
||||||
|
return gui.moveImageSearchSelection(g, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) handleImageSearchNext(g *gocui.Gui, v *gocui.View) error {
|
||||||
|
return gui.moveImageSearchSelection(g, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) moveImageSearchSelection(g *gocui.Gui, change int) error {
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
itemCount := len(state.Results)
|
||||||
|
if state.Mode == imageSearchModeTags {
|
||||||
|
itemCount = len(state.Tags)
|
||||||
|
}
|
||||||
|
if itemCount == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state.SelectedLine += change
|
||||||
|
if state.SelectedLine < 0 {
|
||||||
|
state.SelectedLine = 0
|
||||||
|
}
|
||||||
|
if state.SelectedLine >= itemCount {
|
||||||
|
state.SelectedLine = itemCount - 1
|
||||||
|
}
|
||||||
|
gui.renderImageSearchPanelLocked(g, state)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) handleImageSearchSelect(g *gocui.Gui, v *gocui.View) error {
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
state.mu.Lock()
|
||||||
|
if state.Mode == imageSearchModeRepositories {
|
||||||
|
if len(state.Results) == 0 || state.SelectedLine < 0 || state.SelectedLine >= len(state.Results) {
|
||||||
|
state.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := state.Results[state.SelectedLine]
|
||||||
|
state.mu.Unlock()
|
||||||
|
return gui.beginImageTagSearch(g, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(state.Tags) == 0 || state.SelectedLine < 0 || state.SelectedLine >= len(state.Tags) {
|
||||||
|
state.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tag := state.Tags[state.SelectedLine]
|
||||||
|
imageName := state.SelectedImage.Name
|
||||||
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
if err := gui.closeImageSearchPanel(g, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return gui.pullImageReference(imageName + ":" + tag.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) beginImageTagSearch(g *gocui.Gui, result registry.SearchResult) error {
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
state.mu.Lock()
|
||||||
|
state.Mode = imageSearchModeTags
|
||||||
|
state.SelectedImage = result
|
||||||
|
state.Query = ""
|
||||||
|
state.Results = nil
|
||||||
|
state.Tags = nil
|
||||||
|
state.SelectedLine = 0
|
||||||
|
state.RequestID++
|
||||||
|
state.Searching = true
|
||||||
|
state.SearchError = ""
|
||||||
|
startWorker := !state.WorkerRunning
|
||||||
|
if startWorker {
|
||||||
|
state.WorkerRunning = true
|
||||||
|
}
|
||||||
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
inputView, err := g.View(imageSearchInputViewName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
inputView.ClearTextArea()
|
||||||
|
_ = inputView.SetCursor(0, 0)
|
||||||
|
_ = inputView.SetOrigin(0, 0)
|
||||||
|
if err := gui.layoutImageSearchPanel(g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gui.renderImageSearchPanel(g)
|
||||||
|
if err := gui.renderImageSearchOptions(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if startWorker {
|
||||||
|
go gui.runImageSearchWorker()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) closeImageSearchPanel(g *gocui.Gui, v *gocui.View) error {
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
state.mu.Lock()
|
||||||
|
state.Active = false
|
||||||
|
state.RequestID++
|
||||||
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
g.DeleteViewKeybindings(imageSearchInputViewName)
|
||||||
|
for _, viewName := range []string{imageSearchInputViewName, imageSearchResultsViewName, imageSearchDetailsViewName} {
|
||||||
|
_ = g.DeleteView(viewName)
|
||||||
|
}
|
||||||
|
return gui.returnFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) renderImageSearchPanel(g *gocui.Gui) {
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
gui.renderImageSearchPanelLocked(g, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) renderImageSearchPanelLocked(g *gocui.Gui, state *imageSearchState) {
|
||||||
|
resultsView, resultsErr := g.View(imageSearchResultsViewName)
|
||||||
|
detailsView, detailsErr := g.View(imageSearchDetailsViewName)
|
||||||
|
if resultsErr != nil || detailsErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsView.Title = gui.imageSearchResultsTitle(state)
|
||||||
|
resultsView.Clear()
|
||||||
|
if state.Mode == imageSearchModeTags {
|
||||||
|
gui.renderImageTagResultsLocked(resultsView, state)
|
||||||
|
} else {
|
||||||
|
gui.renderImageRepositoryResultsLocked(resultsView, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
detailsView.Clear()
|
||||||
|
_ = detailsView.SetOrigin(0, 0)
|
||||||
|
_ = detailsView.SetCursor(0, 0)
|
||||||
|
if state.Mode == imageSearchModeTags && len(state.Tags) > 0 && state.SelectedLine >= 0 && state.SelectedLine < len(state.Tags) {
|
||||||
|
fmt.Fprint(detailsView, gui.imageTagSearchDetails(state.SelectedImage.Name, state.Tags[state.SelectedLine]))
|
||||||
|
} else if state.Mode == imageSearchModeRepositories && len(state.Results) > 0 && state.SelectedLine >= 0 && state.SelectedLine < len(state.Results) {
|
||||||
|
fmt.Fprint(detailsView, gui.imageSearchDetails(state.Results[state.SelectedLine]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) renderImageRepositoryResultsLocked(resultsView *gocui.View, state *imageSearchState) {
|
||||||
|
if state.Query == "" {
|
||||||
|
fmt.Fprint(resultsView, gui.Tr.ImageSearchHint)
|
||||||
|
} else if state.Searching {
|
||||||
|
fmt.Fprintf(resultsView, "%s %s...", gui.Tr.SearchingImagesStatus, utils.ColoredString(state.Query, color.FgCyan))
|
||||||
|
} else if state.SearchError != "" {
|
||||||
|
fmt.Fprint(resultsView, utils.ColoredString(gui.Tr.ImageSearchFailed+": "+state.SearchError, color.FgRed))
|
||||||
|
} else if len(state.Results) == 0 {
|
||||||
|
fmt.Fprintf(resultsView, "%s: %s", gui.Tr.NoImageSearchResults, utils.ColoredString(state.Query, color.FgCyan))
|
||||||
|
} else {
|
||||||
|
rows := make([][]string, 0, len(state.Results))
|
||||||
|
for index, result := range state.Results {
|
||||||
|
marker := " "
|
||||||
|
nameColor := color.FgGreen
|
||||||
|
if index == state.SelectedLine {
|
||||||
|
marker = "> "
|
||||||
|
nameColor = color.FgCyan
|
||||||
|
}
|
||||||
|
|
||||||
|
official := ""
|
||||||
|
if result.IsOfficial {
|
||||||
|
official = gui.Tr.OfficialImage
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{
|
||||||
|
marker + utils.ColoredString(result.Name, nameColor),
|
||||||
|
utils.ColoredString(fmt.Sprintf("★ %d", result.StarCount), color.FgMagenta),
|
||||||
|
official,
|
||||||
|
truncateImageSearchText(imageSearchOneLine(result.Description), 72),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderImageSearchRows(resultsView, rows, state.SelectedLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) renderImageTagResultsLocked(resultsView *gocui.View, state *imageSearchState) {
|
||||||
|
if state.Searching {
|
||||||
|
if state.Query == "" {
|
||||||
|
fmt.Fprintf(resultsView, "%s...", gui.Tr.SearchingImageTagsStatus)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(resultsView, "%s %s...", gui.Tr.SearchingImageTagsStatus, utils.ColoredString(state.Query, color.FgCyan))
|
||||||
|
}
|
||||||
|
} else if state.SearchError != "" {
|
||||||
|
fmt.Fprint(resultsView, utils.ColoredString(gui.Tr.ImageTagSearchFailed+": "+state.SearchError, color.FgRed))
|
||||||
|
} else if len(state.Tags) == 0 {
|
||||||
|
fmt.Fprint(resultsView, gui.Tr.NoImageTagsFound)
|
||||||
|
} else {
|
||||||
|
rows := make([][]string, 0, len(state.Tags))
|
||||||
|
for index, tag := range state.Tags {
|
||||||
|
marker := " "
|
||||||
|
nameColor := color.FgGreen
|
||||||
|
if index == state.SelectedLine {
|
||||||
|
marker = "> "
|
||||||
|
nameColor = color.FgCyan
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{
|
||||||
|
marker + utils.ColoredString(tag.Name, nameColor),
|
||||||
|
units.HumanSize(float64(tag.FullSize)),
|
||||||
|
fmt.Sprintf("%d platforms", len(imageTagPlatforms(tag))),
|
||||||
|
formatImageTagDate(tag.TagLastPushed),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderImageSearchRows(resultsView, rows, state.SelectedLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderImageSearchRows(resultsView *gocui.View, rows [][]string, selectedLine int) {
|
||||||
|
rendered, err := utils.RenderTable(rows)
|
||||||
|
if err == nil {
|
||||||
|
fmt.Fprint(resultsView, rendered)
|
||||||
|
}
|
||||||
|
resultsView.FocusPoint(0, selectedLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) imageSearchResultsTitle(state *imageSearchState) string {
|
||||||
|
title := gui.Tr.ImagesTitle
|
||||||
|
itemCount := len(state.Results)
|
||||||
|
if state.Mode == imageSearchModeTags {
|
||||||
|
title = gui.Tr.ImageTagsTitle
|
||||||
|
itemCount = len(state.Tags)
|
||||||
|
}
|
||||||
|
if itemCount == 0 {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s — %d of %d", title, state.SelectedLine+1, itemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) imageSearchDetails(result registry.SearchResult) string {
|
||||||
|
lines := []string{
|
||||||
|
fmt.Sprintf("Name: %s", utils.ColoredString(result.Name, color.FgCyan)),
|
||||||
|
fmt.Sprintf("Stars: %s", utils.ColoredString(fmt.Sprintf("%d", result.StarCount), color.FgMagenta)),
|
||||||
|
}
|
||||||
|
if result.IsOfficial {
|
||||||
|
lines = append(lines, fmt.Sprintf("Type: %s", gui.Tr.OfficialImage))
|
||||||
|
}
|
||||||
|
if result.Description != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Description: %s", imageSearchOneLine(result.Description)))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) imageTagSearchDetails(imageName string, tag commands.DockerHubTag) string {
|
||||||
|
reference := imageName + ":" + tag.Name
|
||||||
|
lines := []string{
|
||||||
|
fmt.Sprintf("Reference: %s", utils.ColoredString(reference, color.FgGreen)),
|
||||||
|
fmt.Sprintf("Size: %s", utils.ColoredString(units.HumanSize(float64(tag.FullSize)), color.FgMagenta)),
|
||||||
|
}
|
||||||
|
if pushed := formatImageTagDate(tag.TagLastPushed); pushed != "" {
|
||||||
|
lines = append(lines, "Last pushed: "+pushed)
|
||||||
|
}
|
||||||
|
if platforms := imageTagPlatforms(tag); len(platforms) > 0 {
|
||||||
|
lines = append(lines, "Platforms: "+strings.Join(platforms, ", "))
|
||||||
|
}
|
||||||
|
if tag.Digest != "" {
|
||||||
|
lines = append(lines, "Digest: "+tag.Digest)
|
||||||
|
}
|
||||||
|
if tag.MediaType != "" {
|
||||||
|
lines = append(lines, "Media type: "+tag.MediaType)
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) imageSearchPanelTitles() (string, string) {
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
if state.Mode == imageSearchModeTags {
|
||||||
|
return fmt.Sprintf("%s %s", gui.Tr.SearchImageTagsTitle, state.SelectedImage.Name), gui.Tr.ImageTagDetailsTitle
|
||||||
|
}
|
||||||
|
return gui.Tr.SearchImagesTitle, gui.Tr.ImageSearchDetailsTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) renderImageSearchOptions() error {
|
||||||
|
state := gui.State.ImageSearch
|
||||||
|
state.mu.Lock()
|
||||||
|
enterAction := gui.Tr.SelectImage
|
||||||
|
if state.Mode == imageSearchModeTags {
|
||||||
|
enterAction = gui.Tr.PullImage
|
||||||
|
}
|
||||||
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
return gui.renderOptionsMap(map[string]string{
|
||||||
|
"↑/↓": gui.Tr.Navigate,
|
||||||
|
"enter": enterAction,
|
||||||
|
"esc": gui.Tr.Close,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) pullImageReference(reference string) error {
|
||||||
|
status := fmt.Sprintf("%s %s", gui.Tr.PullingImageStatus, reference)
|
||||||
|
return gui.WithWaitingStatus(status, func() error {
|
||||||
|
if err := gui.DockerCommand.PullImage(reference); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return gui.reloadImages()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageTagPlatforms(tag commands.DockerHubTag) []string {
|
||||||
|
platformSet := map[string]struct{}{}
|
||||||
|
for _, image := range tag.Images {
|
||||||
|
if image.OS == "" || image.OS == "unknown" || image.Architecture == "" || image.Architecture == "unknown" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
platform := image.OS + "/" + image.Architecture
|
||||||
|
if image.Variant != "" {
|
||||||
|
platform += "/" + image.Variant
|
||||||
|
}
|
||||||
|
platformSet[platform] = struct{}{}
|
||||||
|
}
|
||||||
|
platforms := make([]string, 0, len(platformSet))
|
||||||
|
for platform := range platformSet {
|
||||||
|
platforms = append(platforms, platform)
|
||||||
|
}
|
||||||
|
sort.Strings(platforms)
|
||||||
|
return platforms
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatImageTagDate(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return parsed.Local().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageSearchOneLine(value string) string {
|
||||||
|
return strings.Join(strings.Fields(value), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateImageSearchText(value string, limit int) string {
|
||||||
|
if utf8.RuneCountInString(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if limit <= 3 {
|
||||||
|
return strings.Repeat(".", limit)
|
||||||
|
}
|
||||||
|
runes := []rune(value)
|
||||||
|
return string(runes[:limit-3]) + "..."
|
||||||
|
}
|
||||||
124
pkg/gui/image_search_panel_test.go
Normal file
124
pkg/gui/image_search_panel_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
"github.com/jesseduffield/gocui"
|
||||||
|
"github.com/jesseduffield/lazydocker/pkg/commands"
|
||||||
|
"github.com/jesseduffield/lazydocker/pkg/i18n"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newImageSearchTestGui(t *testing.T) (*Gui, *gocui.Gui) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
g, err := gocui.NewGui(gocui.NewGuiOpts{
|
||||||
|
OutputMode: gocui.OutputTrue,
|
||||||
|
SupportOverlaps: true,
|
||||||
|
Headless: true,
|
||||||
|
Width: 140,
|
||||||
|
Height: 48,
|
||||||
|
})
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { g.Close() })
|
||||||
|
|
||||||
|
log := logrus.New().WithField("test", true)
|
||||||
|
return &Gui{
|
||||||
|
g: g,
|
||||||
|
Log: log,
|
||||||
|
Tr: i18n.NewTranslationSet(log, "en"),
|
||||||
|
State: guiState{ImageSearch: &imageSearchState{}},
|
||||||
|
}, g
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageSearchPanelUsesResultsInputDetailsLayout(t *testing.T) {
|
||||||
|
gui, g := newImageSearchTestGui(t)
|
||||||
|
|
||||||
|
assert.NoError(t, gui.layoutImageSearchPanel(g))
|
||||||
|
|
||||||
|
results, err := g.View(imageSearchResultsViewName)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
input, err := g.View(imageSearchInputViewName)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
details, err := g.View(imageSearchDetailsViewName)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, _, _, resultsBottom := results.Dimensions()
|
||||||
|
_, inputTop, _, inputBottom := input.Dimensions()
|
||||||
|
_, detailsTop, _, _ := details.Dimensions()
|
||||||
|
assert.Less(t, resultsBottom, inputTop)
|
||||||
|
assert.Equal(t, inputTop+2, inputBottom)
|
||||||
|
assert.Less(t, inputBottom, detailsTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageSearchPanelRendersSelectionAndDetails(t *testing.T) {
|
||||||
|
gui, g := newImageSearchTestGui(t)
|
||||||
|
assert.NoError(t, gui.layoutImageSearchPanel(g))
|
||||||
|
|
||||||
|
gui.State.ImageSearch.Query = "nginx"
|
||||||
|
gui.State.ImageSearch.Results = []registry.SearchResult{
|
||||||
|
{Name: "nginx", Description: "Official NGINX image", StarCount: 21000, IsOfficial: true},
|
||||||
|
{Name: "bitnami/nginx", Description: "Bitnami NGINX image", StarCount: 200},
|
||||||
|
}
|
||||||
|
gui.renderImageSearchPanel(g)
|
||||||
|
|
||||||
|
results, _ := g.View(imageSearchResultsViewName)
|
||||||
|
details, _ := g.View(imageSearchDetailsViewName)
|
||||||
|
assert.Contains(t, results.Buffer(), "> ")
|
||||||
|
assert.Contains(t, results.Buffer(), "nginx")
|
||||||
|
assert.Contains(t, details.Buffer(), "Name:")
|
||||||
|
assert.Contains(t, details.Buffer(), "Official NGINX image")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageSearchPanelRendersTagMetadata(t *testing.T) {
|
||||||
|
gui, g := newImageSearchTestGui(t)
|
||||||
|
gui.State.ImageSearch.Mode = imageSearchModeTags
|
||||||
|
gui.State.ImageSearch.SelectedImage = registry.SearchResult{Name: "nginx"}
|
||||||
|
assert.NoError(t, gui.layoutImageSearchPanel(g))
|
||||||
|
|
||||||
|
gui.State.ImageSearch.Tags = []commands.DockerHubTag{
|
||||||
|
{
|
||||||
|
Name: "1.27-alpine",
|
||||||
|
FullSize: 75270706,
|
||||||
|
TagLastPushed: "2026-07-15T12:30:00Z",
|
||||||
|
Digest: "sha256:abc123",
|
||||||
|
MediaType: "application/vnd.oci.image.index.v1+json",
|
||||||
|
Images: []commands.DockerHubTagVariant{
|
||||||
|
{OS: "linux", Architecture: "amd64", Size: 75270706},
|
||||||
|
{OS: "linux", Architecture: "arm64", Variant: "v8", Size: 70000000},
|
||||||
|
{OS: "unknown", Architecture: "unknown"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
gui.renderImageSearchPanel(g)
|
||||||
|
|
||||||
|
results, _ := g.View(imageSearchResultsViewName)
|
||||||
|
details, _ := g.View(imageSearchDetailsViewName)
|
||||||
|
input, _ := g.View(imageSearchInputViewName)
|
||||||
|
assert.Contains(t, results.Buffer(), "1.27-alpine")
|
||||||
|
assert.Contains(t, results.Buffer(), "2 platforms")
|
||||||
|
assert.Contains(t, details.Buffer(), "nginx:1.27-alpine")
|
||||||
|
assert.Contains(t, details.Buffer(), "linux/amd64")
|
||||||
|
assert.Contains(t, details.Buffer(), "linux/arm64/v8")
|
||||||
|
assert.Contains(t, details.Buffer(), "sha256:abc123")
|
||||||
|
assert.Contains(t, input.Title, "nginx")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageTagPlatformsAreUniqueAndSorted(t *testing.T) {
|
||||||
|
tag := commands.DockerHubTag{Images: []commands.DockerHubTagVariant{
|
||||||
|
{OS: "linux", Architecture: "arm64", Variant: "v8"},
|
||||||
|
{OS: "linux", Architecture: "amd64"},
|
||||||
|
{OS: "linux", Architecture: "amd64"},
|
||||||
|
{OS: "unknown", Architecture: "unknown"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"linux/amd64", "linux/arm64/v8"}, imageTagPlatforms(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateImageSearchTextPreservesUnicode(t *testing.T) {
|
||||||
|
assert.Equal(t, "конт...", truncateImageSearchText("контейнер", 7))
|
||||||
|
}
|
||||||
|
|
@ -367,6 +367,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||||
Handler: gui.handleServicesOpenInBrowserCommand,
|
Handler: gui.handleServicesOpenInBrowserCommand,
|
||||||
Description: gui.Tr.OpenInBrowser,
|
Description: gui.Tr.OpenInBrowser,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ViewName: "images",
|
||||||
|
Key: 'I',
|
||||||
|
Modifier: gocui.ModNone,
|
||||||
|
Handler: gui.handleSearchImages,
|
||||||
|
Description: gui.Tr.SearchImages,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ViewName: "images",
|
ViewName: "images",
|
||||||
Key: 'c',
|
Key: 'c',
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,9 @@ func (gui *Gui) currentViewName() string {
|
||||||
|
|
||||||
func (gui *Gui) resizeCurrentPopupPanel(g *gocui.Gui) error {
|
func (gui *Gui) resizeCurrentPopupPanel(g *gocui.Gui) error {
|
||||||
v := g.CurrentView()
|
v := g.CurrentView()
|
||||||
|
if v != nil && v.Name() == imageSearchInputViewName {
|
||||||
|
return gui.layoutImageSearchPanel(g)
|
||||||
|
}
|
||||||
if gui.isPopupPanel(v.Name()) {
|
if gui.isPopupPanel(v.Name()) {
|
||||||
return gui.resizePopupPanel(v)
|
return gui.resizePopupPanel(v)
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +233,8 @@ func (gui *Gui) renderPanelOptions() error {
|
||||||
return gui.renderMenuOptions()
|
return gui.renderMenuOptions()
|
||||||
case "confirmation":
|
case "confirmation":
|
||||||
return gui.renderConfirmationOptions()
|
return gui.renderConfirmationOptions()
|
||||||
|
case imageSearchInputViewName:
|
||||||
|
return gui.renderImageSearchOptions()
|
||||||
}
|
}
|
||||||
return gui.renderGlobalOptions()
|
return gui.renderGlobalOptions()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,13 @@ func (gui *Gui) getInformationContent() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) popupViewNames() []string {
|
func (gui *Gui) popupViewNames() []string {
|
||||||
return []string{"confirmation", "menu"}
|
return []string{
|
||||||
|
"confirmation",
|
||||||
|
"menu",
|
||||||
|
imageSearchResultsViewName,
|
||||||
|
imageSearchInputViewName,
|
||||||
|
imageSearchDetailsViewName,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// these views have their position and size determined by arrangement.go
|
// these views have their position and size determined by arrangement.go
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,23 @@ type TranslationSet struct {
|
||||||
FilterList string
|
FilterList string
|
||||||
OpenInBrowser string
|
OpenInBrowser string
|
||||||
SortContainersByState string
|
SortContainersByState string
|
||||||
|
SearchImages string
|
||||||
|
SearchImagesTitle string
|
||||||
|
SearchingImagesStatus string
|
||||||
|
NoImageSearchResults string
|
||||||
|
PullingImageStatus string
|
||||||
|
OfficialImage string
|
||||||
|
ImageSearchFailed string
|
||||||
|
ImageSearchDetailsTitle string
|
||||||
|
ImageSearchHint string
|
||||||
|
SearchImageTagsTitle string
|
||||||
|
SearchingImageTagsStatus string
|
||||||
|
ImageTagSearchFailed string
|
||||||
|
NoImageTagsFound string
|
||||||
|
ImageTagsTitle string
|
||||||
|
ImageTagDetailsTitle string
|
||||||
|
SelectImage string
|
||||||
|
PullImage string
|
||||||
|
|
||||||
LogsTitle string
|
LogsTitle string
|
||||||
ConfigTitle string
|
ConfigTitle string
|
||||||
|
|
@ -217,6 +234,23 @@ func englishSet() TranslationSet {
|
||||||
FilterList: "filter list",
|
FilterList: "filter list",
|
||||||
OpenInBrowser: "open in browser (first port is http)",
|
OpenInBrowser: "open in browser (first port is http)",
|
||||||
SortContainersByState: "sort containers by state",
|
SortContainersByState: "sort containers by state",
|
||||||
|
SearchImages: "search and pull image",
|
||||||
|
SearchImagesTitle: "Search Docker Hub images",
|
||||||
|
SearchingImagesStatus: "searching Docker Hub",
|
||||||
|
NoImageSearchResults: "No images found",
|
||||||
|
PullingImageStatus: "pulling image",
|
||||||
|
OfficialImage: "official",
|
||||||
|
ImageSearchFailed: "Docker Hub search failed",
|
||||||
|
ImageSearchDetailsTitle: "Image details",
|
||||||
|
ImageSearchHint: "Type an image name to search Docker Hub",
|
||||||
|
SearchImageTagsTitle: "Search tags for",
|
||||||
|
SearchingImageTagsStatus: "searching Docker Hub tags",
|
||||||
|
ImageTagSearchFailed: "Docker Hub tag search failed",
|
||||||
|
NoImageTagsFound: "No tags found",
|
||||||
|
ImageTagsTitle: "Tags",
|
||||||
|
ImageTagDetailsTitle: "Tag details",
|
||||||
|
SelectImage: "view tags",
|
||||||
|
PullImage: "pull image",
|
||||||
|
|
||||||
GlobalTitle: "Global",
|
GlobalTitle: "Global",
|
||||||
MainTitle: "Main",
|
MainTitle: "Main",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue