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