This commit is contained in:
Nika Sumbadze 2026-07-16 09:05:31 +00:00 committed by GitHub
commit 22397be89a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1101 additions and 1 deletions

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>: 查看批量命令

View file

@ -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{})

View 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)
}

View 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)
}

View 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())
}

View file

@ -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

View file

@ -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{

View 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]) + "..."
}

View 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))
}

View file

@ -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',

View file

@ -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()
}

View file

@ -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

View file

@ -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",