diff --git a/pkg/commands/container.go b/pkg/commands/container.go index 3ad3e602..68499b22 100644 --- a/pkg/commands/container.go +++ b/pkg/commands/container.go @@ -9,10 +9,11 @@ import ( "github.com/docker/docker/client" "github.com/fatih/color" "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/sirupsen/logrus" "golang.org/x/xerrors" ) -// Container : A git Container +// Container : A docker Container type Container struct { Name string ServiceName string @@ -21,6 +22,7 @@ type Container struct { DisplayString string Client *client.Client OSCommand *OSCommand + Log *logrus.Entry } // GetDisplayStrings returns the dispaly string of Container diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index e3dff420..5f5649da 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -2,7 +2,6 @@ package commands import ( "context" - "fmt" "strings" "github.com/davecgh/go-spew/spew" @@ -48,8 +47,6 @@ func (c *DockerCommand) GetContainers() ([]*Container, error) { ownContainers := make([]*Container, len(containers)) for i, container := range containers { - c.Log.Warn(spew.Sdump(container)) - c.Log.Warn(fmt.Sprintf("%s %s\n", container.ID[:10], container.Image)) serviceName, ok := container.Labels["com.docker.compose.service"] if !ok { serviceName = "" @@ -62,8 +59,40 @@ func (c *DockerCommand) GetContainers() ([]*Container, error) { Container: container, Client: c.Client, OSCommand: c.OSCommand, + Log: c.Log, } } return ownContainers, nil } + +// GetImages returns a slice of docker images +func (c *DockerCommand) GetImages() ([]*Image, error) { + images, err := c.Client.ImageList(context.Background(), types.ImageListOptions{}) + if err != nil { + return nil, err + } + + ownImages := make([]*Image, len(images)) + + for i, image := range images { + c.Log.Warn(spew.Sdump(image)) + + name := "none" + tags := image.RepoTags + if len(tags) > 0 { + name = tags[0] + } + + ownImages[i] = &Image{ + ID: image.ID, + Name: name, + Image: image, + Client: c.Client, + OSCommand: c.OSCommand, + Log: c.Log, + } + } + + return ownImages, nil +} diff --git a/pkg/commands/image.go b/pkg/commands/image.go new file mode 100644 index 00000000..a06fdc3e --- /dev/null +++ b/pkg/commands/image.go @@ -0,0 +1,24 @@ +package commands + +import ( + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" + "github.com/fatih/color" + "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/sirupsen/logrus" +) + +// Image : A docker Image +type Image struct { + Name string + ID string + Image types.ImageSummary + Client *client.Client + OSCommand *OSCommand + Log *logrus.Entry +} + +// GetDisplayStrings returns the display string of Image +func (i *Image) GetDisplayStrings(isFocused bool) []string { + return []string{utils.ColoredString(i.Name, color.FgWhite)} +} diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index c42e52d1..f03bece0 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -91,15 +91,15 @@ func (gui *Gui) handleContainerSelect(g *gocui.Gui, v *gocui.View) error { switch gui.getContainerContexts()[gui.State.Panels.Containers.ContextIndex] { case "logs": - if err := gui.renderLogs(mainView, container, writerID); err != nil { + if err := gui.renderContainerLogs(mainView, container, writerID); err != nil { return err } case "config": - if err := gui.renderConfig(mainView, container, writerID); err != nil { + if err := gui.renderContainerConfig(mainView, container, writerID); err != nil { return err } case "stats": - if err := gui.renderStats(mainView, container, writerID); err != nil { + if err := gui.renderContainerStats(mainView, container, writerID); err != nil { return err } default: @@ -109,7 +109,7 @@ func (gui *Gui) handleContainerSelect(g *gocui.Gui, v *gocui.View) error { return nil } -func (gui *Gui) renderConfig(mainView *gocui.View, container *commands.Container, writerID int) error { +func (gui *Gui) renderContainerConfig(mainView *gocui.View, container *commands.Container, writerID int) error { mainView.Autoscroll = false mainView.Title = "Config" @@ -122,7 +122,7 @@ func (gui *Gui) renderConfig(mainView *gocui.View, container *commands.Container return nil } -func (gui *Gui) renderStats(mainView *gocui.View, container *commands.Container, writerID int) error { +func (gui *Gui) renderContainerStats(mainView *gocui.View, container *commands.Container, writerID int) error { mainView.Autoscroll = false mainView.Title = "Stats" @@ -171,7 +171,7 @@ func (gui *Gui) renderStats(mainView *gocui.View, container *commands.Container, return nil } -func (gui *Gui) renderLogs(mainView *gocui.View, container *commands.Container, writerID int) error { +func (gui *Gui) renderContainerLogs(mainView *gocui.View, container *commands.Container, writerID int) error { mainView.Autoscroll = true mainView.Title = "Logs" diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 8ebacecc..eb03bf64 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -35,6 +35,7 @@ var OverlappingEdges = false type SentinelErrors struct { ErrSubProcess error ErrNoContainers error + ErrNoImages error } // GenerateSentinelErrors makes the sentinel errors for the gui. We're defining it here @@ -50,7 +51,8 @@ type SentinelErrors struct { func (gui *Gui) GenerateSentinelErrors() { gui.Errors = SentinelErrors{ ErrSubProcess: errors.New(gui.Tr.SLocalize("RunningSubprocess")), - ErrNoContainers: errors.New(gui.Tr.SLocalize("NoChangedContainers")), + ErrNoContainers: errors.New(gui.Tr.SLocalize("NoContainers")), + ErrNoImages: errors.New(gui.Tr.SLocalize("NoImages")), } } @@ -87,14 +89,21 @@ type mainPanelState struct { WriterID int } +type imagePanelState struct { + SelectedLine int + ContextIndex int // for specifying if you are looking at logs/stats/config/etc +} + type panelStates struct { Containers *containerPanelState Menu *menuPanelState Main *mainPanelState + Images *imagePanelState } type guiState struct { Containers []*commands.Container + Images []*commands.Image MenuItemCount int // can't store the actual list because it's of interface{} type PreviousView string Platform commands.Platform @@ -112,6 +121,7 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand Platform: *oSCommand.Platform, Panels: &panelStates{ Containers: &containerPanelState{SelectedLine: -1, ContextIndex: 0}, + Images: &imagePanelState{SelectedLine: -1, ContextIndex: 0}, Menu: &menuPanelState{SelectedLine: 0}, Main: &mainPanelState{ WriterID: 0, @@ -207,7 +217,7 @@ func (gui *Gui) onFocusLost(v *gocui.View, newView *gocui.View) error { if v == nil { return nil } - if v.Name() == "containers" { + if v.Name() == "containers" || v.Name() == "images" { gui.State.Panels.Main.WriterID++ } gui.Log.Info(v.Name() + " focus lost") @@ -265,7 +275,8 @@ func (gui *Gui) layout(g *gocui.Gui) error { vHeights := map[string]int{ "status": 3, - "containers": usableSpace, + "containers": usableSpace/2 + usableSpace%2, + "images": usableSpace / 2, "options": 1, } @@ -277,6 +288,7 @@ func (gui *Gui) layout(g *gocui.Gui) error { vHeights = map[string]int{ "status": defaultHeight, "containers": defaultHeight, + "images": defaultHeight, "options": defaultHeight, } vHeights[currentCyclebleView] = height - defaultHeight*4 - 1 @@ -318,7 +330,17 @@ func (gui *Gui) layout(g *gocui.Gui) error { } containersView.Highlight = true containersView.Title = gui.Tr.SLocalize("ContainersTitle") - v.FgColor = gocui.ColorWhite + containersView.FgColor = gocui.ColorWhite + } + + imagesView, err := g.SetViewBeneath("images", "containers", vHeights["images"]) + if err != nil { + if err.Error() != "unknown view" { + return err + } + imagesView.Highlight = true + imagesView.Title = gui.Tr.SLocalize("ImagesTitle") + imagesView.FgColor = gocui.ColorWhite } if v, err := g.SetView("options", appStatusOptionsBoundary-1, height-2, optionsVersionBoundary-1, height, 0); err != nil { @@ -387,6 +409,7 @@ func (gui *Gui) layout(g *gocui.Gui) error { listViews := map[*gocui.View]listViewState{ containersView: {selectedLine: gui.State.Panels.Containers.SelectedLine, lineCount: len(gui.State.Containers)}, + imagesView: {selectedLine: gui.State.Panels.Images.SelectedLine, lineCount: len(gui.State.Images)}, } // menu view might not exist so we check to be safe diff --git a/pkg/gui/images_panel.go b/pkg/gui/images_panel.go new file mode 100644 index 00000000..182ea873 --- /dev/null +++ b/pkg/gui/images_panel.go @@ -0,0 +1,272 @@ +package gui + +import ( + "encoding/json" + "fmt" + + "github.com/go-errors/errors" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazydocker/pkg/commands" + "github.com/jesseduffield/lazydocker/pkg/utils" +) + +// list panel functions + +func (gui *Gui) getImageContexts() []string { + return []string{"config"} +} + +func (gui *Gui) getSelectedImage(g *gocui.Gui) (*commands.Image, error) { + selectedLine := gui.State.Panels.Images.SelectedLine + if selectedLine == -1 { + return &commands.Image{}, gui.Errors.ErrNoImages + } + + return gui.State.Images[selectedLine], nil +} + +func (gui *Gui) handleImagesFocus(g *gocui.Gui, v *gocui.View) error { + if gui.popupPanelFocused() { + return nil + } + + cx, cy := v.Cursor() + _, oy := v.Origin() + + prevSelectedLine := gui.State.Panels.Images.SelectedLine + newSelectedLine := cy - oy + + if newSelectedLine > len(gui.State.Images)-1 || len(utils.Decolorise(gui.State.Images[newSelectedLine].Name)) < cx { + return gui.handleImageSelect(gui.g, v) + } + + gui.State.Panels.Images.SelectedLine = newSelectedLine + + if prevSelectedLine == newSelectedLine && gui.currentViewName() == v.Name() { + return gui.handleImagePress(gui.g, v) + } else { + return gui.handleImageSelect(gui.g, v) + } +} + +func (gui *Gui) handleImageSelect(g *gocui.Gui, v *gocui.View) error { + if _, err := gui.g.SetCurrentView(v.Name()); err != nil { + return err + } + + Image, err := gui.getSelectedImage(g) + if err != nil { + if err != gui.Errors.ErrNoImages { + return err + } + return gui.renderString(g, "main", gui.Tr.SLocalize("NoImages")) + } + + key := Image.ID + "-" + gui.getImageContexts()[gui.State.Panels.Images.ContextIndex] + if gui.State.Panels.Main.ObjectKey == key { + return nil + } else { + gui.State.Panels.Main.ObjectKey = key + } + + if err := gui.focusPoint(0, gui.State.Panels.Images.SelectedLine, len(gui.State.Images), v); err != nil { + return err + } + + mainView := gui.getMainView() + + gui.State.Panels.Main.WriterID++ + writerID := gui.State.Panels.Main.WriterID + + mainView.Clear() + mainView.SetOrigin(0, 0) + mainView.SetCursor(0, 0) + + switch gui.getImageContexts()[gui.State.Panels.Images.ContextIndex] { + case "config": + if err := gui.renderImageConfig(mainView, Image, writerID); err != nil { + return err + } + default: + return errors.New("Unknown context for Images panel") + } + + return nil +} + +func (gui *Gui) renderImageConfig(mainView *gocui.View, Image *commands.Image, writerID int) error { + mainView.Autoscroll = false + mainView.Title = "Config" + + data, err := json.MarshalIndent(&Image.Image, "", " ") + if err != nil { + return err + } + gui.renderString(gui.g, "main", string(data)) + + return nil +} + +func (gui *Gui) refreshImages() error { + ImagesView := gui.getImagesView() + if ImagesView == nil { + // if the ImagesView hasn't been instantiated yet we just return + return nil + } + if err := gui.refreshStateImages(); err != nil { + return err + } + + if len(gui.State.Images) > 0 && gui.State.Panels.Images.SelectedLine == -1 { + gui.State.Panels.Images.SelectedLine = 0 + } + if len(gui.State.Images)-1 < gui.State.Panels.Images.SelectedLine { + gui.State.Panels.Images.SelectedLine = len(gui.State.Images) - 1 + } + + gui.g.Update(func(g *gocui.Gui) error { + + ImagesView.Clear() + isFocused := gui.g.CurrentView().Name() == "Images" + list, err := utils.RenderList(gui.State.Images, isFocused) + if err != nil { + return err + } + fmt.Fprint(ImagesView, list) + + if ImagesView == g.CurrentView() { + return gui.handleImageSelect(g, ImagesView) + } + return nil + }) + + return nil +} + +func (gui *Gui) refreshStateImages() error { + Images, err := gui.DockerCommand.GetImages() + if err != nil { + return err + } + + gui.State.Images = Images + + return nil +} + +func (gui *Gui) handleImagesNextLine(g *gocui.Gui, v *gocui.View) error { + if gui.popupPanelFocused() { + return nil + } + + panelState := gui.State.Panels.Images + gui.changeSelectedLine(&panelState.SelectedLine, len(gui.State.Images), false) + + return gui.handleImageSelect(gui.g, v) +} + +func (gui *Gui) handleImagesPrevLine(g *gocui.Gui, v *gocui.View) error { + if gui.popupPanelFocused() { + return nil + } + + panelState := gui.State.Panels.Images + gui.changeSelectedLine(&panelState.SelectedLine, len(gui.State.Images), true) + + return gui.handleImageSelect(gui.g, v) +} + +func (gui *Gui) handleImagePress(g *gocui.Gui, v *gocui.View) error { + return nil +} + +func (gui *Gui) handleImagesPrevContext(g *gocui.Gui, v *gocui.View) error { + contexts := gui.getImageContexts() + if gui.State.Panels.Images.ContextIndex >= len(contexts)-1 { + gui.State.Panels.Images.ContextIndex = 0 + } else { + gui.State.Panels.Images.ContextIndex++ + } + + gui.handleImageSelect(gui.g, v) + + return nil +} + +func (gui *Gui) handleImagesNextContext(g *gocui.Gui, v *gocui.View) error { + contexts := gui.getImageContexts() + if gui.State.Panels.Images.ContextIndex <= 0 { + gui.State.Panels.Images.ContextIndex = len(contexts) - 1 + } else { + gui.State.Panels.Images.ContextIndex-- + } + + gui.handleImageSelect(gui.g, v) + + return nil +} + +// type removeOption struct { +// description string +// command string +// configOptions types.ImageRemoveOptions +// runCommand bool +// } + +// // GetDisplayStrings is a function. +// func (r *removeOption) GetDisplayStrings(isFocused bool) []string { +// return []string{r.description, color.New(color.FgRed).Sprint(r.command)} +// } + +// func (gui *Gui) handleImagesRemoveMenu(g *gocui.Gui, v *gocui.View) error { +// Image, err := gui.getSelectedImage(g) +// if err != nil { +// return nil +// } + +// options := []*removeOption{ +// { +// description: gui.Tr.SLocalize("remove"), +// command: "docker rm " + Image.ID[1:10], +// configOptions: types.ImageRemoveOptions{}, +// runCommand: true, +// }, +// { +// description: gui.Tr.SLocalize("removeWithVolumes"), +// command: "docker rm --volumes " + Image.ID[1:10], +// configOptions: types.ImageRemoveOptions{RemoveVolumes: true}, +// runCommand: true, +// }, +// { +// description: gui.Tr.SLocalize("cancel"), +// runCommand: false, +// }, +// } + +// handleMenuPress := func(index int) error { +// if !options[index].runCommand { +// return nil +// } +// configOptions := options[index].configOptions +// if cerr := Image.Remove(configOptions); cerr != nil { +// var originalErr commands.ComplexError +// if xerrors.As(cerr, &originalErr) { +// if originalErr.Code == commands.MustStopImage { +// return gui.createConfirmationPanel(gui.g, v, gui.Tr.SLocalize("Confirm"), gui.Tr.SLocalize("mustForceToRemove"), func(g *gocui.Gui, v *gocui.View) error { +// configOptions.Force = true +// if err := Image.Remove(configOptions); err != nil { +// return err +// } +// return gui.refreshImages() +// }, nil) +// } +// } else { +// return gui.createErrorPanel(gui.g, err.Error()) +// } +// } + +// return gui.refreshImages() +// } + +// return gui.createMenu("", options, len(options), handleMenuPress) +// } diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 2576ca4d..95ecaa3e 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -179,10 +179,23 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Handler: gui.handleContainerAttach, Description: gui.Tr.SLocalize("attachContainer"), }, + { + ViewName: "images", + Key: '[', + Modifier: gocui.ModNone, + Handler: gui.handleImagesPrevContext, + Description: gui.Tr.SLocalize("previousContext"), + }, { + ViewName: "images", + Key: ']', + Modifier: gocui.ModNone, + Handler: gui.handleImagesNextContext, + Description: gui.Tr.SLocalize("nextContext"), + }, } // TODO: add more views here - for _, viewName := range []string{"status", "containers", "menu"} { + for _, viewName := range []string{"status", "containers", "images", "menu"} { bindings = append(bindings, []*Binding{ {ViewName: viewName, Key: gocui.KeyTab, Modifier: gocui.ModNone, Handler: gui.nextView}, {ViewName: viewName, Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: gui.previousView}, @@ -199,6 +212,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { }{ "menu": {prevLine: gui.handleMenuPrevLine, nextLine: gui.handleMenuNextLine, focus: gui.handleMenuSelect}, "containers": {prevLine: gui.handleContainersPrevLine, nextLine: gui.handleContainersNextLine, focus: gui.handleContainersFocus}, + "images": {prevLine: gui.handleImagesPrevLine, nextLine: gui.handleImagesNextLine, focus: gui.handleImagesFocus}, } for viewName, functions := range listPanelMap { diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index ce16f9ba..d386e971 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -10,12 +10,15 @@ import ( "github.com/spkg/bom" ) -var cyclableViews = []string{"status", "containers"} +var cyclableViews = []string{"status", "containers", "images"} func (gui *Gui) refreshSidePanels(g *gocui.Gui) error { if err := gui.refreshContainers(); err != nil { return err } + if err := gui.refreshImages(); err != nil { + return err + } return nil } @@ -77,6 +80,8 @@ func (gui *Gui) newLineFocused(g *gocui.Gui, v *gocui.View) error { return gui.handleStatusSelect(g, v) case "containers": return gui.handleContainerSelect(g, v) + case "images": + return gui.handleImageSelect(g, v) case "confirmation": return nil case "main": @@ -218,6 +223,11 @@ func (gui *Gui) getContainersView() *gocui.View { return v } +func (gui *Gui) getImagesView() *gocui.View { + v, _ := gui.g.View("images") + return v +} + func (gui *Gui) getMainView() *gocui.View { v, _ := gui.g.View("main") return v diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 4025e3d5..863b4c5e 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -27,9 +27,6 @@ func addEnglish(i18nObject *i18n.Bundle) error { }, &i18n.Message{ ID: "LogTitle", Other: "Log", - }, &i18n.Message{ - ID: "ContainersTitle", - Other: "Containers", }, &i18n.Message{ ID: "BranchesTitle", Other: "Branches", @@ -150,9 +147,6 @@ func addEnglish(i18nObject *i18n.Bundle) error { }, &i18n.Message{ ID: "checkout", Other: "checkout", - }, &i18n.Message{ - ID: "NoChangedContainers", - Other: "No changed containers", }, &i18n.Message{ ID: "ContainerHasNoUnstagedChanges", Other: "Container has no unstaged changes to add", @@ -810,5 +804,21 @@ func addEnglish(i18nObject *i18n.Bundle) error { ID: "attachContainer", Other: "attach to container", }, + &i18n.Message{ + ID: "ContainersTitle", + Other: "Containers", + }, + &i18n.Message{ + ID: "ImagesTitle", + Other: "Images", + }, + &i18n.Message{ + ID: "NoContainers", + Other: "No containers", + }, + &i18n.Message{ + ID: "NoImages", + Other: "No images", + }, ) }