From 10fa1dd770ba133f565a6fc965713ce02086c9fa Mon Sep 17 00:00:00 2001 From: Jochen Schalanda Date: Wed, 6 Nov 2024 19:56:22 +0100 Subject: [PATCH] feat: add CTRL+e for extracting current focused file Refs wagoodman/dive#224 Co-authored-by: kaedwen --- cmd/root.go | 1 + dive/image/docker/archive_resolver.go | 4 ++ dive/image/docker/engine_resolver.go | 13 ++++ dive/image/docker/image_archive.go | 78 +++++++++++++++++++++++ dive/image/podman/resolver.go | 15 +++++ dive/image/podman/resolver_unsupported.go | 4 ++ dive/image/resolver.go | 1 + runtime/run.go | 2 +- runtime/run_test.go | 12 ++++ runtime/ui/app.go | 8 +-- runtime/ui/controller.go | 21 ++++-- runtime/ui/view/filetree.go | 24 +++++++ runtime/ui/viewmodel/filetree.go | 5 ++ 13 files changed, 178 insertions(+), 10 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 5074bbeb..a230ff38 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -93,6 +93,7 @@ func initConfig() { viper.SetDefault("keybinding.toggle-modified-files", "ctrl+m") viper.SetDefault("keybinding.toggle-unmodified-files", "ctrl+u") viper.SetDefault("keybinding.toggle-wrap-tree", "ctrl+p") + viper.SetDefault("keybinding.extract-file", "ctrl+e") viper.SetDefault("keybinding.page-up", "pgup") viper.SetDefault("keybinding.page-down", "pgdn") diff --git a/dive/image/docker/archive_resolver.go b/dive/image/docker/archive_resolver.go index fbe04981..5404837f 100644 --- a/dive/image/docker/archive_resolver.go +++ b/dive/image/docker/archive_resolver.go @@ -35,3 +35,7 @@ func (r *archiveResolver) Fetch(path string) (*image.Image, error) { func (r *archiveResolver) Build(args []string) (*image.Image, error) { return nil, fmt.Errorf("build option not supported for docker archive resolver") } + +func (r *archiveResolver) Extract(id string, l string, p string) error { + return fmt.Errorf("not implemented") +} diff --git a/dive/image/docker/engine_resolver.go b/dive/image/docker/engine_resolver.go index 4e787ef6..aa3f0318 100644 --- a/dive/image/docker/engine_resolver.go +++ b/dive/image/docker/engine_resolver.go @@ -50,6 +50,19 @@ func (r *engineResolver) Build(args []string) (*image.Image, error) { return r.Fetch(id) } +func (r *engineResolver) Extract(id string, l string, p string) error { + reader, err := r.fetchArchive(id) + if err != nil { + return err + } + + if err := ExtractFromImage(io.NopCloser(reader), l, p); err == nil { + return nil + } + + return fmt.Errorf("unable to extract from image '%s': %+v", id, err) +} + func (r *engineResolver) fetchArchive(id string) (io.ReadCloser, error) { var err error var dockerClient *client.Client diff --git a/dive/image/docker/image_archive.go b/dive/image/docker/image_archive.go index f90c45c3..c2eda0c9 100644 --- a/dive/image/docker/image_archive.go +++ b/dive/image/docker/image_archive.go @@ -9,6 +9,7 @@ import ( "io" "os" "path" + "path/filepath" "strings" "github.com/wagoodman/dive/dive/filetree" @@ -253,3 +254,80 @@ func (img *ImageArchive) ToImage() (*image.Image, error) { Layers: layers, }, nil } + +func ExtractFromImage(tarFile io.ReadCloser, l string, p string) error { + tarReader := tar.NewReader(tarFile) + + for { + header, err := tarReader.Next() + + if err == io.EOF { + break + } + + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + name := header.Name + + switch header.Typeflag { + case tar.TypeReg: + if name == l { + err = extractInner(tar.NewReader(tarReader), p) + if err != nil { + return err + } + return nil + } + default: + continue + } + } + + return nil +} + +func extractInner(reader *tar.Reader, p string) error { + target := strings.TrimPrefix(p, "/") + + for { + header, err := reader.Next() + + if err == io.EOF { + break + } + + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + name := header.Name + + switch header.Typeflag { + case tar.TypeReg: + if strings.HasPrefix(name, target) { + err := os.MkdirAll(filepath.Dir(name), 0755) + if err != nil { + return err + } + + out, err := os.Create(name) + if err != nil { + return err + } + + _, err = io.Copy(out, reader) + if err != nil { + return err + } + } + default: + continue + } + } + + return nil +} diff --git a/dive/image/podman/resolver.go b/dive/image/podman/resolver.go index 3e479949..80852a6d 100644 --- a/dive/image/podman/resolver.go +++ b/dive/image/podman/resolver.go @@ -41,6 +41,21 @@ func (r *resolver) Fetch(id string) (*image.Image, error) { return nil, fmt.Errorf("unable to resolve image '%s': %+v", id, err) } +func (r *resolver) Extract(id string, l string, p string) error { + // todo: add podman fetch attempt via varlink first... + + err, reader := streamPodmanCmd("image", "save", id) + if err != nil { + return err + } + + if err := docker.ExtractFromImage(io.NopCloser(reader), l, p); err == nil { + return nil + } + + return fmt.Errorf("unable to extract from image '%s': %+v", id, err) +} + func (r *resolver) resolveFromDockerArchive(id string) (*image.Image, error) { err, reader := streamPodmanCmd("image", "save", id) if err != nil { diff --git a/dive/image/podman/resolver_unsupported.go b/dive/image/podman/resolver_unsupported.go index 6cbcdff1..120f62e7 100644 --- a/dive/image/podman/resolver_unsupported.go +++ b/dive/image/podman/resolver_unsupported.go @@ -26,3 +26,7 @@ func (r *resolver) Build(args []string) (*image.Image, error) { func (r *resolver) Fetch(id string) (*image.Image, error) { return nil, fmt.Errorf("unsupported platform") } + +func (r *resolver) Extract(id string, l string, p string) error { + return fmt.Errorf("unsupported platform") +} diff --git a/dive/image/resolver.go b/dive/image/resolver.go index 51634fe5..f3999b9e 100644 --- a/dive/image/resolver.go +++ b/dive/image/resolver.go @@ -4,4 +4,5 @@ type Resolver interface { Name() string Fetch(id string) (*Image, error) Build(options []string) (*Image, error) + Extract(id string, layer string, path string) error } diff --git a/runtime/run.go b/runtime/run.go index 49d591e4..7e4814c0 100644 --- a/runtime/run.go +++ b/runtime/run.go @@ -108,7 +108,7 @@ func run(enableUi bool, options Options, imageResolver image.Resolver, events ev // enough sleep will prevent this behavior (todo: remove this hack) time.Sleep(100 * time.Millisecond) - err = ui.Run(options.Image, analysis, treeStack) + err = ui.Run(options.Image, imageResolver, analysis, treeStack) if err != nil { events.exitWithError(err) return diff --git a/runtime/run_test.go b/runtime/run_test.go index 37d7a2c7..6362ea78 100644 --- a/runtime/run_test.go +++ b/runtime/run_test.go @@ -20,6 +20,10 @@ func (r *defaultResolver) Name() string { return "default-resolver" } +func (r *defaultResolver) Extract(id string, l string, p string) error { + return nil +} + func (r *defaultResolver) Fetch(id string) (*image.Image, error) { archive, err := docker.TestLoadArchive("../.data/test-docker-image.tar") if err != nil { @@ -38,6 +42,10 @@ func (r *failedBuildResolver) Name() string { return "failed-build-resolver" } +func (r *failedBuildResolver) Extract(id string, l string, p string) error { + return fmt.Errorf("some extract failure") +} + func (r *failedBuildResolver) Fetch(id string) (*image.Image, error) { archive, err := docker.TestLoadArchive("../.data/test-docker-image.tar") if err != nil { @@ -56,6 +64,10 @@ func (r *failedFetchResolver) Name() string { return "failed-fetch-resolver" } +func (r *failedFetchResolver) Extract(id string, l string, p string) error { + return fmt.Errorf("some extract failure") +} + func (r *failedFetchResolver) Fetch(id string) (*image.Image, error) { return nil, fmt.Errorf("some fetch failure") } diff --git a/runtime/ui/app.go b/runtime/ui/app.go index 608a6648..85ce5845 100644 --- a/runtime/ui/app.go +++ b/runtime/ui/app.go @@ -27,13 +27,13 @@ var ( appSingleton *app ) -func newApp(gui *gocui.Gui, imageName string, analysis *image.AnalysisResult, cache filetree.Comparer) (*app, error) { +func newApp(gui *gocui.Gui, imageName string, resolver image.Resolver, analysis *image.AnalysisResult, cache filetree.Comparer) (*app, error) { var err error once.Do(func() { var controller *Controller var globalHelpKeys []*key.Binding - controller, err = NewCollection(gui, imageName, analysis, cache) + controller, err = NewCollection(gui, imageName, resolver, analysis, cache) if err != nil { return } @@ -134,7 +134,7 @@ func (a *app) quit() error { } // Run is the UI entrypoint. -func Run(imageName string, analysis *image.AnalysisResult, treeStack filetree.Comparer) error { +func Run(imageName string, resolver image.Resolver, analysis *image.AnalysisResult, treeStack filetree.Comparer) error { var err error g, err := gocui.NewGui(gocui.OutputNormal, true) @@ -143,7 +143,7 @@ func Run(imageName string, analysis *image.AnalysisResult, treeStack filetree.Co } defer g.Close() - _, err = newApp(g, imageName, analysis, treeStack) + _, err = newApp(g, imageName, resolver, analysis, treeStack) if err != nil { return err } diff --git a/runtime/ui/controller.go b/runtime/ui/controller.go index 031955df..9f3fbe6a 100644 --- a/runtime/ui/controller.go +++ b/runtime/ui/controller.go @@ -13,19 +13,23 @@ import ( ) type Controller struct { - gui *gocui.Gui - views *view.Views + gui *gocui.Gui + views *view.Views + resolver image.Resolver + imageName string } -func NewCollection(g *gocui.Gui, imageName string, analysis *image.AnalysisResult, cache filetree.Comparer) (*Controller, error) { +func NewCollection(g *gocui.Gui, imageName string, resolver image.Resolver, analysis *image.AnalysisResult, cache filetree.Comparer) (*Controller, error) { views, err := view.NewViews(g, imageName, analysis, cache) if err != nil { return nil, err } controller := &Controller{ - gui: g, - views: views, + gui: g, + views: views, + resolver: resolver, + imageName: imageName, } // layer view cursor down event should trigger an update in the file tree @@ -34,6 +38,9 @@ func NewCollection(g *gocui.Gui, imageName string, analysis *image.AnalysisResul // update the status pane when a filetree option is changed by the user controller.views.Tree.AddViewOptionChangeListener(controller.onFileTreeViewOptionChange) + // update the status pane when a filetree option is changed by the user + controller.views.Tree.AddViewExtractListener(controller.onFileTreeViewExtract) + // update the tree view while the user types into the filter view controller.views.Filter.AddFilterEditListener(controller.onFilterEdit) @@ -53,6 +60,10 @@ func NewCollection(g *gocui.Gui, imageName string, analysis *image.AnalysisResul return controller, nil } +func (c *Controller) onFileTreeViewExtract(p string) error { + return c.resolver.Extract(c.imageName, c.views.LayerDetails.CurrentLayer.Id, p) +} + func (c *Controller) onFileTreeViewOptionChange() error { err := c.views.Status.Update() if err != nil { diff --git a/runtime/ui/view/filetree.go b/runtime/ui/view/filetree.go index c302ec62..55717458 100644 --- a/runtime/ui/view/filetree.go +++ b/runtime/ui/view/filetree.go @@ -17,6 +17,8 @@ import ( type ViewOptionChangeListener func() error +type ViewExtractListener func(string) error + // FileTree holds the UI objects and data models for populating the right pane. Specifically the pane that // shows selected layer or aggregate file ASCII tree. type FileTree struct { @@ -29,6 +31,7 @@ type FileTree struct { filterRegex *regexp.Regexp listeners []ViewOptionChangeListener + extractListeners []ViewExtractListener helpKeys []*key.Binding requestedWidthRatio float64 } @@ -60,6 +63,10 @@ func (v *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListe v.listeners = append(v.listeners, listener...) } +func (v *FileTree) AddViewExtractListener(listener ...ViewExtractListener) { + v.extractListeners = append(v.extractListeners, listener...) +} + func (v *FileTree) SetTitle(title string) { v.title = title } @@ -103,6 +110,11 @@ func (v *FileTree) Setup(view, header *gocui.View) error { OnAction: v.toggleSortOrder, Display: "Toggle sort order", }, + { + ConfigKeys: []string{"keybinding.extract-file"}, + OnAction: v.extractFile, + Display: "Extract File", + }, { ConfigKeys: []string{"keybinding.toggle-added-files"}, OnAction: func() error { return v.toggleShowDiffType(filetree.Added) }, @@ -303,6 +315,18 @@ func (v *FileTree) toggleSortOrder() error { return v.Render() } +func (v *FileTree) extractFile() error { + node := v.vm.CurrentNode(v.filterRegex) + for _, listener := range v.extractListeners { + err := listener(node.Path()) + if err != nil { + return err + } + } + + return nil +} + func (v *FileTree) toggleWrapTree() error { v.view.Wrap = !v.view.Wrap diff --git a/runtime/ui/viewmodel/filetree.go b/runtime/ui/viewmodel/filetree.go index afb01732..a5f87d0a 100644 --- a/runtime/ui/viewmodel/filetree.go +++ b/runtime/ui/viewmodel/filetree.go @@ -161,6 +161,11 @@ func (vm *FileTreeViewModel) CursorDown() bool { return true } +// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree +func (vm *FileTreeViewModel) CurrentNode(filterRegex *regexp.Regexp) *filetree.FileNode { + return vm.getAbsPositionNode(filterRegex) +} + // CursorLeft moves the cursor up until we reach the Parent Node or top of the tree func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error { var visitor func(*filetree.FileNode) error