diff --git a/docs/keybindings/Keybindings_de.md b/docs/keybindings/Keybindings_de.md index dc9dd08e..3419f937 100644 --- a/docs/keybindings/Keybindings_de.md +++ b/docs/keybindings/Keybindings_de.md @@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct ## Images
+  I: search and pull image
   c: führe vordefinierten benutzerdefinierten Befehl aus
   d: entferne Image
   b: view bulk commands
diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md
index ae21c4dc..40897a58 100644
--- a/docs/keybindings/Keybindings_en.md
+++ b/docs/keybindings/Keybindings_en.md
@@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
 ## Images
 
 
+  I: search and pull image
   c: run predefined custom command
   d: remove image
   b: view bulk commands
diff --git a/docs/keybindings/Keybindings_es.md b/docs/keybindings/Keybindings_es.md
index 6b4eb921..e1f1bdec 100644
--- a/docs/keybindings/Keybindings_es.md
+++ b/docs/keybindings/Keybindings_es.md
@@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
 ## Imágenes
 
 
+  I: search and pull image
   c: ejecutar comando personalizado
   d: limpiar imagen
   b: ver comandos masivos
diff --git a/docs/keybindings/Keybindings_fr.md b/docs/keybindings/Keybindings_fr.md
index 109078b2..7d2b8be1 100644
--- a/docs/keybindings/Keybindings_fr.md
+++ b/docs/keybindings/Keybindings_fr.md
@@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
 ## Images
 
 
+  I: search and pull image
   c: exécuter une commande prédéfinie
   d: supprimer l'image
   b: voir les commandes groupées
diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md
index 24477006..605f5871 100644
--- a/docs/keybindings/Keybindings_nl.md
+++ b/docs/keybindings/Keybindings_nl.md
@@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
 ## Images
 
 
+  I: search and pull image
   c: draai een vooraf bedacht aangepaste opdracht
   d: verwijder image
   b: view bulk commands
diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md
index 2f4cb93f..3cd601b6 100644
--- a/docs/keybindings/Keybindings_pl.md
+++ b/docs/keybindings/Keybindings_pl.md
@@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
 ## Obrazy
 
 
+  I: search and pull image
   c: wykonaj predefiniowaną własną komende
   d: usuń obraz
   b: view bulk commands
diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md
index 32a557b5..0877d6ed 100644
--- a/docs/keybindings/Keybindings_pt.md
+++ b/docs/keybindings/Keybindings_pt.md
@@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
 ## Imagens
 
 
+  I: search and pull image
   c: executar comando personalizado predefinido
   d: remover imagem
   b: ver comandos em massa
diff --git a/docs/keybindings/Keybindings_tr.md b/docs/keybindings/Keybindings_tr.md
index 3192a7c2..d98f31d9 100644
--- a/docs/keybindings/Keybindings_tr.md
+++ b/docs/keybindings/Keybindings_tr.md
@@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
 ## Imajlar
 
 
+  I: search and pull image
   c: önceden tanımlanmış özel komutu çalıştır
   d: imajı kaldır
   b: view bulk commands
diff --git a/docs/keybindings/Keybindings_zh.md b/docs/keybindings/Keybindings_zh.md
index 0c75fbcb..5b56be71 100644
--- a/docs/keybindings/Keybindings_zh.md
+++ b/docs/keybindings/Keybindings_zh.md
@@ -61,6 +61,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
 ## 镜像
 
 
+  I: search and pull image
   c: 运行预定义的自定义命令
   d: 移除镜像
   b: 查看批量命令
diff --git a/pkg/commands/image.go b/pkg/commands/image.go
index 789733d0..fbf4c759 100644
--- a/pkg/commands/image.go
+++ b/pkg/commands/image.go
@@ -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{})
diff --git a/pkg/commands/image_registry.go b/pkg/commands/image_registry.go
new file mode 100644
index 00000000..13259126
--- /dev/null
+++ b/pkg/commands/image_registry.go
@@ -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)
+}
diff --git a/pkg/commands/image_registry_test.go b/pkg/commands/image_registry_test.go
new file mode 100644
index 00000000..ad05eb39
--- /dev/null
+++ b/pkg/commands/image_registry_test.go
@@ -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)
+}
diff --git a/pkg/commands/image_test.go b/pkg/commands/image_test.go
new file mode 100644
index 00000000..809582eb
--- /dev/null
+++ b/pkg/commands/image_test.go
@@ -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())
+}
diff --git a/pkg/gui/focus.go b/pkg/gui/focus.go
index cc414a53..b37eb1d6 100644
--- a/pkg/gui/focus.go
+++ b/pkg/gui/focus.go
@@ -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
diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go
index 6ea2ee6c..7db05122 100644
--- a/pkg/gui/gui.go
+++ b/pkg/gui/gui.go
@@ -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{
diff --git a/pkg/gui/image_search_panel.go b/pkg/gui/image_search_panel.go
new file mode 100644
index 00000000..58df7aab
--- /dev/null
+++ b/pkg/gui/image_search_panel.go
@@ -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]) + "..."
+}
diff --git a/pkg/gui/image_search_panel_test.go b/pkg/gui/image_search_panel_test.go
new file mode 100644
index 00000000..a0be193e
--- /dev/null
+++ b/pkg/gui/image_search_panel_test.go
@@ -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))
+}
diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go
index df706024..4269f870 100644
--- a/pkg/gui/keybindings.go
+++ b/pkg/gui/keybindings.go
@@ -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',
diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go
index 6acbb18c..fb3be8aa 100644
--- a/pkg/gui/view_helpers.go
+++ b/pkg/gui/view_helpers.go
@@ -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()
 }
diff --git a/pkg/gui/views.go b/pkg/gui/views.go
index f231c3c6..eafb1f39 100644
--- a/pkg/gui/views.go
+++ b/pkg/gui/views.go
@@ -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
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index d86b0c84..b847ba4c 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.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",