mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-22 07:11:01 +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
|
||||
[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
|
||||
|
||||
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
|
||||
|
||||
<pre>
|
||||
<kbd>I</kbd>: search and pull image
|
||||
<kbd>c</kbd>: führe vordefinierten benutzerdefinierten Befehl aus
|
||||
<kbd>d</kbd>: entferne Image
|
||||
<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
|
||||
|
||||
<pre>
|
||||
<kbd>I</kbd>: search and pull image
|
||||
<kbd>c</kbd>: run predefined custom command
|
||||
<kbd>d</kbd>: remove image
|
||||
<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
|
||||
|
||||
<pre>
|
||||
<kbd>I</kbd>: search and pull image
|
||||
<kbd>c</kbd>: ejecutar comando personalizado
|
||||
<kbd>d</kbd>: limpiar imagen
|
||||
<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
|
||||
|
||||
<pre>
|
||||
<kbd>I</kbd>: search and pull image
|
||||
<kbd>c</kbd>: exécuter une commande prédéfinie
|
||||
<kbd>d</kbd>: supprimer l'image
|
||||
<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
|
||||
|
||||
<pre>
|
||||
<kbd>I</kbd>: search and pull image
|
||||
<kbd>c</kbd>: draai een vooraf bedacht aangepaste opdracht
|
||||
<kbd>d</kbd>: verwijder image
|
||||
<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
|
||||
|
||||
<pre>
|
||||
<kbd>I</kbd>: search and pull image
|
||||
<kbd>c</kbd>: wykonaj predefiniowaną własną komende
|
||||
<kbd>d</kbd>: usuń obraz
|
||||
<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
|
||||
|
||||
<pre>
|
||||
<kbd>I</kbd>: search and pull image
|
||||
<kbd>c</kbd>: executar comando personalizado predefinido
|
||||
<kbd>d</kbd>: remover imagem
|
||||
<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
|
||||
|
||||
<pre>
|
||||
<kbd>I</kbd>: search and pull image
|
||||
<kbd>c</kbd>: önceden tanımlanmış özel komutu çalıştır
|
||||
<kbd>d</kbd>: imajı kaldır
|
||||
<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>
|
||||
<kbd>I</kbd>: search and pull image
|
||||
<kbd>c</kbd>: 运行预定义的自定义命令
|
||||
<kbd>d</kbd>: 移除镜像
|
||||
<kbd>b</kbd>: 查看批量命令
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@ package commands
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
cliconfig "github.com/docker/cli/cli/config"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/fatih/color"
|
||||
"github.com/jesseduffield/lazydocker/pkg/utils"
|
||||
|
|
@ -13,6 +18,11 @@ import (
|
|||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
dockerHubRegistryAddress = "https://index.docker.io/v1/"
|
||||
imageSearchResultLimit = 50
|
||||
)
|
||||
|
||||
// Image : A docker Image
|
||||
type Image struct {
|
||||
Name string
|
||||
|
|
@ -136,6 +146,78 @@ func (c *DockerCommand) RefreshImages() ([]*Image, error) {
|
|||
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
|
||||
func (c *DockerCommand) PruneImages() error {
|
||||
_, 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() {
|
||||
case "confirmation":
|
||||
return nil
|
||||
case imageSearchInputViewName:
|
||||
return nil
|
||||
case "main":
|
||||
v.Highlight = false
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ type guiState struct {
|
|||
// Maintains the state of manual filtering i.e. typing in a substring
|
||||
// to filter on in the current panel.
|
||||
Filter filterState
|
||||
|
||||
ImageSearch *imageSearchState
|
||||
}
|
||||
|
||||
type filterState struct {
|
||||
|
|
@ -137,6 +139,7 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand
|
|||
|
||||
ShowExitedContainers: true,
|
||||
ScreenMode: getScreenMode(config),
|
||||
ImageSearch: &imageSearchState{},
|
||||
}
|
||||
|
||||
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,
|
||||
Description: gui.Tr.OpenInBrowser,
|
||||
},
|
||||
{
|
||||
ViewName: "images",
|
||||
Key: 'I',
|
||||
Modifier: gocui.ModNone,
|
||||
Handler: gui.handleSearchImages,
|
||||
Description: gui.Tr.SearchImages,
|
||||
},
|
||||
{
|
||||
ViewName: "images",
|
||||
Key: 'c',
|
||||
|
|
|
|||
|
|
@ -204,6 +204,9 @@ func (gui *Gui) currentViewName() string {
|
|||
|
||||
func (gui *Gui) resizeCurrentPopupPanel(g *gocui.Gui) error {
|
||||
v := g.CurrentView()
|
||||
if v != nil && v.Name() == imageSearchInputViewName {
|
||||
return gui.layoutImageSearchPanel(g)
|
||||
}
|
||||
if gui.isPopupPanel(v.Name()) {
|
||||
return gui.resizePopupPanel(v)
|
||||
}
|
||||
|
|
@ -230,6 +233,8 @@ func (gui *Gui) renderPanelOptions() error {
|
|||
return gui.renderMenuOptions()
|
||||
case "confirmation":
|
||||
return gui.renderConfirmationOptions()
|
||||
case imageSearchInputViewName:
|
||||
return gui.renderImageSearchOptions()
|
||||
}
|
||||
return gui.renderGlobalOptions()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,13 @@ func (gui *Gui) getInformationContent() 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
|
||||
|
|
|
|||
|
|
@ -109,6 +109,23 @@ type TranslationSet struct {
|
|||
FilterList string
|
||||
OpenInBrowser 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
|
||||
ConfigTitle string
|
||||
|
|
@ -217,6 +234,23 @@ func englishSet() TranslationSet {
|
|||
FilterList: "filter list",
|
||||
OpenInBrowser: "open in browser (first port is http)",
|
||||
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",
|
||||
MainTitle: "Main",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue