[geary/mjog/account-command-stacks: 7/25] Clean up attachment save code



commit de6ef699de5845ce0749a934943b7bbd8606b6bc
Author: Michael Gratton <mike vee net>
Date:   Tue Oct 29 12:15:47 2019 +1100

    Clean up attachment save code
    
    Create new Application.AttachmentManager class and move code for saving
    attachments there from both Application.Controller and MainWindow since
    aside from needing the latter for dialogs it is independent of both.
    
    Create new Components.AttachmentPane widget for diplaying attachents
    for an email in the ConversationViewer.
    
    Update ConversationEmail and ConversationMessage to use these two new
    classes directly, rather than implementing save management itself or
    requiring the MainWindow to wire up signals on these classes.

 po/POTFILES.in                                     |   6 +-
 .../application-attachment-manager.vala            | 284 +++++++++++
 src/client/application/application-controller.vala | 231 ---------
 .../components/components-attachment-pane.vala     | 524 +++++++++++++++++++++
 src/client/components/main-window.vala             |  61 +--
 .../conversation-viewer/conversation-email.vala    | 241 ++--------
 .../conversation-viewer/conversation-message.vala  |  46 +-
 src/client/meson.build                             |   2 +
 src/client/util/util-gtk.vala                      |  51 +-
 ui/components-attachment-pane-menus.ui             |  22 +
 ui/components-attachment-pane.ui                   | 151 ++++++
 ...hment-view.ui => components-attachment-view.ui} |   4 +-
 ui/conversation-email-menus.ui                     |  18 -
 ui/conversation-email.ui                           | 118 +----
 ui/geary.css                                       |  10 +
 ui/org.gnome.Geary.gresource.xml                   |   4 +-
 16 files changed, 1137 insertions(+), 636 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index bd269db8..993c0673 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -14,6 +14,7 @@ src/client/accounts/accounts-editor-row.vala
 src/client/accounts/accounts-editor-servers-pane.vala
 src/client/accounts/accounts-manager.vala
 src/client/accounts/accounts-signature-web-view.vala
+src/client/application/application-attachment-manager.vala
 src/client/application/application-avatar-store.vala
 src/client/application/application-certificate-manager.vala
 src/client/application/application-command.vala
@@ -28,6 +29,7 @@ src/client/application/goa-mediator.vala
 src/client/application/main.vala
 src/client/application/secret-mediator.vala
 src/client/components/client-web-view.vala
+src/client/components/components-attachment-pane.vala
 src/client/components/components-in-app-notification.vala
 src/client/components/components-inspector.vala
 src/client/components/components-placeholder-pane.vala
@@ -412,6 +414,9 @@ ui/composer-headerbar.ui
 ui/composer-link-popover.ui
 ui/composer-menus.ui
 ui/composer-widget.ui
+ui/components-attachment-pane.ui
+ui/components-attachment-pane-menus.ui
+ui/components-attachment-view.ui
 ui/components-in-app-notification.ui
 ui/components-inspector-error-view.ui
 ui/components-inspector-log-view.ui
@@ -419,7 +424,6 @@ ui/components-inspector.ui
 ui/components-placeholder-pane.ui
 ui/conversation-contact-popover.ui
 ui/conversation-email.ui
-ui/conversation-email-attachment-view.ui
 ui/conversation-email-menus.ui
 ui/conversation-message-menus.ui
 ui/conversation-message.ui
diff --git a/src/client/application/application-attachment-manager.vala 
b/src/client/application/application-attachment-manager.vala
new file mode 100644
index 00000000..805ecb6f
--- /dev/null
+++ b/src/client/application/application-attachment-manager.vala
@@ -0,0 +1,284 @@
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2019 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/*
+ * Manages downloading and saving email attachment parts.
+ */
+public class Application.AttachmentManager : GLib.Object {
+
+
+    public static string untitled_file_name;
+
+
+    static construct {
+        // Translators: File name used in save chooser when saving
+        // attachments that do not otherwise have a name.
+        AttachmentManager.untitled_file_name = _("Untitled");
+    }
+
+
+    private weak MainWindow parent;
+
+
+    public AttachmentManager(MainWindow parent) {
+        this.parent = parent;
+    }
+
+    /**
+     * Saves multiple attachments to disk, prompting for destination.
+     *
+     * Prompt for both a location and for confirmation before
+     * overwriting existing files. Files are written with their
+     * existing names. Returns true if written to disk, else false.
+     */
+    public async bool save_attachments(Gee.Collection<Geary.Attachment> attachments,
+                                       GLib.Cancellable? cancellable) {
+        if (attachments.size == 1) {
+            return yield save_attachment(
+                Geary.Collection.get_first(attachments), null, cancellable
+            );
+        } else {
+            return yield save_all(attachments, cancellable);
+        }
+    }
+
+    /**
+     * Saves single attachment to disk, prompting for name and destination.
+     *
+     * Prompt for both a name and location and for confirmation before
+     * overwriting existing files. Returns true if written to disk,
+     * else false.
+     */
+    public async bool save_attachment(Geary.Attachment attachment,
+                                      string? alt_name,
+                                      GLib.Cancellable? cancellable) {
+        string alt_display_name = Geary.String.is_empty_or_whitespace(alt_name)
+            ? AttachmentManager.untitled_file_name : alt_name;
+        string display_name = yield attachment.get_safe_file_name(
+            alt_display_name
+        );
+
+        Geary.Memory.Buffer? content = yield open_buffer(
+            attachment, cancellable
+        );
+
+        bool succeeded = false;
+        if (content != null) {
+            succeeded = yield this.save_buffer(
+                display_name, content, cancellable
+            );
+        }
+        return succeeded;
+    }
+
+    /**
+     * Saves a buffer to disk as if it was an attachment.
+     *
+     * Prompt for both a name and location and for confirmation before
+     * overwriting existing files. Returns true if written to disk,
+     * else false.
+     */
+    public async bool save_buffer(string display_name,
+                                   Geary.Memory.Buffer buffer,
+                                   GLib.Cancellable? cancellable) {
+        Gtk.FileChooserNative dialog = new_save_chooser(SAVE);
+        dialog.set_current_name(display_name);
+
+        string? destination_uri = null;
+        if (dialog.run() == Gtk.ResponseType.ACCEPT) {
+            destination_uri = dialog.get_uri();
+        }
+        dialog.destroy();
+
+        bool succeeded = false;
+        if (!Geary.String.is_empty_or_whitespace(destination_uri)) {
+            succeeded = yield check_and_write(
+                buffer, GLib.File.new_for_uri(destination_uri), cancellable
+            );
+        }
+        return succeeded;
+    }
+
+    private async bool save_all(Gee.Collection<Geary.Attachment> attachments,
+                                GLib.Cancellable? cancellable) {
+        var dialog = new_save_chooser(SELECT_FOLDER);
+        string? destination_uri = null;
+        if (dialog.run() == Gtk.ResponseType.ACCEPT) {
+            destination_uri = dialog.get_uri();
+        }
+        dialog.destroy();
+
+        bool succeeded = false;
+        if (!Geary.String.is_empty_or_whitespace(destination_uri)) {
+            var destination_dir = GLib.File.new_for_uri(destination_uri);
+            foreach (Geary.Attachment attachment in attachments) {
+                GLib.File? destination = null;
+                try {
+                    destination = destination_dir.get_child_for_display_name(
+                        yield attachment.get_safe_file_name(
+                            AttachmentManager.untitled_file_name
+                        )
+                    );
+                } catch (GLib.IOError.CANCELLED err) {
+                    // Everything is going to fail from now on, so get
+                    // out of here
+                    succeeded = false;
+                    break;
+                } catch (GLib.Error err) {
+                    warning(
+                        "Error determining file system name for \"%s\": %s",
+                        attachment.file.get_uri(), err.message
+                    );
+                    handle_error(err);
+                }
+                var content = yield open_buffer(attachment, cancellable);
+                if (content != null &&
+                    destination != null) {
+                    succeeded &= yield check_and_write(
+                        content, destination, cancellable
+                    );
+                } else {
+                    succeeded = false;
+                }
+            }
+        }
+        return succeeded;
+    }
+
+    private async Geary.Memory.Buffer open_buffer(Geary.Attachment attachment,
+                                                  GLib.Cancellable? cancellable) {
+        Geary.Memory.FileBuffer? content = null;
+        try {
+            yield Geary.Nonblocking.Concurrent.global.schedule_async(
+                () => {
+                    content = new Geary.Memory.FileBuffer(attachment.file, true);
+                },
+                cancellable
+            );
+        } catch (GLib.Error err) {
+            warning(
+                "Error opening attachment file \"%s\": %s",
+                attachment.file.get_uri(), err.message
+            );
+            handle_error(err);
+        }
+        return content;
+    }
+
+    private async bool check_and_write(Geary.Memory.Buffer content,
+                                       GLib.File destination,
+                                       GLib.Cancellable? cancellable) {
+        bool succeeded = false;
+        try {
+            if (yield check_overwrite(destination, cancellable)) {
+                yield write_buffer_to_file(content, destination, cancellable);
+                succeeded = true;
+            }
+        } catch (GLib.Error err) {
+            warning(
+                "Error saving attachment \"%s\": %s",
+                destination.get_uri(), err.message
+            );
+            handle_error(err);
+        }
+        return succeeded;
+    }
+
+    private async bool check_overwrite(GLib.File to_overwrite,
+                                       GLib.Cancellable? cancellable)
+        throws GLib.Error {
+        string target_name = "";
+        string parent_name = "";
+        try {
+            GLib.FileInfo file_info = yield to_overwrite.query_info_async(
+                GLib.FileAttribute.STANDARD_DISPLAY_NAME,
+                GLib.FileQueryInfoFlags.NONE,
+                GLib.Priority.DEFAULT,
+                cancellable
+            );
+            target_name = file_info.get_display_name();
+            GLib.FileInfo parent_info = yield to_overwrite.get_parent()
+                .query_info_async(
+                    GLib.FileAttribute.STANDARD_DISPLAY_NAME,
+                    GLib.FileQueryInfoFlags.NONE,
+                    GLib.Priority.DEFAULT,
+                    cancellable
+                );
+            parent_name = parent_info.get_display_name();
+        } catch (GLib.IOError.NOT_FOUND err) {
+            // All good
+            return true;
+        }
+
+        /// Translators: Dialog primary label when prompting to
+        /// overwrite a file. The string substitution is the file'sx
+        /// name.
+        string primary = _(
+            "A file named “%s” already exists.  Do you want to replace it?"
+        ).printf(target_name);
+
+        /// Translators: Dialog secondary label when prompting to
+        /// overwrite a file. The string substitution is the parent
+        /// folder's name.
+        string secondary = _(
+            "The file already exists in “%s”.  Replacing it will overwrite its contents."
+        ).printf(parent_name);
+
+        ConfirmationDialog dialog = new ConfirmationDialog(
+            this.parent,
+            primary,
+            secondary,
+            _("_Replace"),
+            "destructive-action"
+        );
+        return (dialog.run() == Gtk.ResponseType.OK);
+    }
+
+    private async void write_buffer_to_file(Geary.Memory.Buffer buffer,
+                                           GLib.File destination,
+                                           GLib.Cancellable? cancellable)
+        throws GLib.Error {
+        try {
+            GLib.FileOutputStream outs = destination.replace(
+                null, false, REPLACE_DESTINATION, cancellable
+            );
+            yield outs.splice_async(
+                buffer.get_input_stream(),
+                CLOSE_SOURCE | CLOSE_TARGET,
+                GLib.Priority.DEFAULT,
+                cancellable
+            );
+        } catch (GLib.IOError.CANCELLED err) {
+            try {
+                yield destination.delete_async(GLib.Priority.HIGH, null);
+            } catch (GLib.Error err) {
+                // Oh well
+            }
+            throw err;
+        }
+    }
+
+    private inline Gtk.FileChooserNative new_save_chooser(Gtk.FileChooserAction action) {
+        Gtk.FileChooserNative dialog = new Gtk.FileChooserNative(
+            null,
+            this.parent,
+            action,
+            Stock._SAVE,
+            Stock._CANCEL
+        );
+        dialog.set_local_only(false);
+        return dialog;
+    }
+
+    private inline void handle_error(GLib.Error error) {
+        this.parent.application.controller.report_problem(
+            new Geary.ProblemReport(error)
+        );
+    }
+
+}
diff --git a/src/client/application/application-controller.vala 
b/src/client/application/application-controller.vala
index b41fcb56..bcfda6a8 100644
--- a/src/client/application/application-controller.vala
+++ b/src/client/application/application-controller.vala
@@ -20,15 +20,6 @@ public class Application.Controller : Geary.BaseObject {
     private const int SELECT_FOLDER_TIMEOUT_USEC = 100 * 1000;
     private const uint MAX_AUTH_ATTEMPTS = 3;
 
-    private static string untitled_file_name;
-
-
-    static construct {
-        // Translators: File name used in save chooser when saving
-        // attachments that do not otherwise have a name.
-        Controller.untitled_file_name = _("Untitled");
-    }
-
 
     /**
      * Collects objects and state related to a single open account.
@@ -1650,228 +1641,6 @@ public class Application.Controller : Geary.BaseObject {
         }
     }
 
-    public async void save_attachment_to_file(Geary.Account account,
-                                              Geary.Attachment attachment,
-                                              string? alt_text) {
-        AccountContext? context = this.accounts.get(account.information);
-        GLib.Cancellable cancellable = (
-            context != null ? context.cancellable : null
-        );
-
-        string alt_display_name = Geary.String.is_empty_or_whitespace(alt_text)
-            ? Application.Controller.untitled_file_name : alt_text;
-        string display_name = yield attachment.get_safe_file_name(
-            alt_display_name
-        );
-
-        Geary.Memory.FileBuffer? content = null;
-        try {
-            content = new Geary.Memory.FileBuffer(attachment.file, true);
-        } catch (GLib.Error err) {
-            warning(
-                "Error opening attachment file \"%s\": %s",
-                attachment.file.get_uri(), err.message
-            );
-            report_problem(new Geary.ProblemReport(err));
-        }
-
-        yield this.prompt_save_buffer(display_name, content, cancellable);
-    }
-
-    public async void
-        save_attachments_to_file(Geary.Account account,
-                                 Gee.Collection<Geary.Attachment> attachments) {
-        AccountContext? context = this.accounts.get(account.information);
-        GLib.Cancellable cancellable = (
-            context != null ? context.cancellable : null
-        );
-
-        Gtk.FileChooserNative dialog = new_save_chooser(Gtk.FileChooserAction.SELECT_FOLDER);
-
-        bool accepted = (dialog.run() == Gtk.ResponseType.ACCEPT);
-        string? filename = dialog.get_filename();
-        dialog.destroy();
-        if (!accepted || Geary.String.is_empty(filename))
-            return;
-
-        File dest_dir = File.new_for_path(filename);
-        foreach (Geary.Attachment attachment in attachments) {
-            Geary.Memory.FileBuffer? content = null;
-            GLib.File? dest = null;
-            try {
-                content = new Geary.Memory.FileBuffer(attachment.file, true);
-                dest = dest_dir.get_child_for_display_name(
-                    yield attachment.get_safe_file_name(
-                        Application.Controller.untitled_file_name
-                    )
-                );
-            } catch (GLib.Error err) {
-                warning(
-                    "Error opening attachment files \"%s\": %s",
-                    attachment.file.get_uri(), err.message
-                );
-                report_problem(new Geary.ProblemReport(err));
-            }
-
-            if (content != null &&
-                dest != null &&
-                yield check_overwrite(dest, cancellable)) {
-                yield write_buffer_to_file(content, dest, cancellable);
-            }
-        }
-    }
-
-    public async void save_image_extended(Geary.Account account,
-                                          ConversationEmail view,
-                                          string url,
-                                          string? alt_text,
-                                          Geary.Memory.Buffer resource_buf) {
-        AccountContext? context = this.accounts.get(account.information);
-        GLib.Cancellable cancellable = (
-            context != null ? context.cancellable : null
-        );
-
-        // This is going to be either an inline image, or a remote
-        // image, so either treat it as an attachment to assume we'll
-        // have a valid filename in the URL
-        bool handled = false;
-        if (url.has_prefix(ClientWebView.CID_URL_PREFIX)) {
-            string cid = url.substring(ClientWebView.CID_URL_PREFIX.length);
-            Geary.Attachment? attachment = null;
-            try {
-                attachment = view.email.get_attachment_by_content_id(cid);
-            } catch (Error err) {
-                debug("Could not get attachment \"%s\": %s", cid, err.message);
-            }
-            if (attachment != null) {
-                yield this.save_attachment_to_file(
-                    account, attachment, alt_text
-                );
-                handled = true;
-            }
-        }
-
-        if (!handled) {
-            GLib.File source = GLib.File.new_for_uri(url);
-            // Querying the URL-based file for the display name
-            // results in it being looked up, so just get the basename
-            // from it directly. GIO seems to decode any %-encoded
-            // chars anyway.
-            string? display_name = source.get_basename();
-            if (Geary.String.is_empty_or_whitespace(display_name)) {
-                display_name = Controller.untitled_file_name;
-            }
-
-            yield this.prompt_save_buffer(
-                display_name, resource_buf, cancellable
-            );
-        }
-    }
-
-    private async void prompt_save_buffer(string display_name,
-                                          Geary.Memory.Buffer buffer,
-                                          GLib.Cancellable? cancellable) {
-        Gtk.FileChooserNative dialog = new_save_chooser(
-            Gtk.FileChooserAction.SAVE
-        );
-        dialog.set_current_name(display_name);
-
-        string? accepted_path = null;
-        if (dialog.run() == Gtk.ResponseType.ACCEPT) {
-            accepted_path = dialog.get_filename();
-        }
-        dialog.destroy();
-
-        if (!Geary.String.is_empty_or_whitespace(accepted_path)) {
-            GLib.File dest_file = File.new_for_path(accepted_path);
-            if (yield check_overwrite(dest_file, cancellable)) {
-                yield write_buffer_to_file(buffer, dest_file, cancellable);
-            }
-        }
-    }
-
-    private async bool check_overwrite(GLib.File to_overwrite,
-                                       GLib.Cancellable? cancellable) {
-        bool overwrite = true;
-        try {
-            GLib.FileInfo file_info = yield to_overwrite.query_info_async(
-                GLib.FileAttribute.STANDARD_DISPLAY_NAME,
-                GLib.FileQueryInfoFlags.NONE,
-                GLib.Priority.DEFAULT,
-                cancellable
-            );
-            GLib.FileInfo parent_info = yield to_overwrite.get_parent()
-                .query_info_async(
-                    GLib.FileAttribute.STANDARD_DISPLAY_NAME,
-                    GLib.FileQueryInfoFlags.NONE,
-                    GLib.Priority.DEFAULT,
-                    cancellable
-                );
-
-            // Translators: Dialog primary label when prompting to
-            // overwrite a file. The string substitution is the file'sx
-            // name.
-            string primary = _(
-                "A file named “%s” already exists.  Do you want to replace it?"
-            ).printf(file_info.get_display_name());
-
-            // Translators: Dialog secondary label when prompting to
-            // overwrite a file. The string substitution is the parent
-            // folder's name.
-            string secondary = _(
-                "The file already exists in “%s”.  Replacing it will overwrite its contents."
-            ).printf(parent_info.get_display_name());
-
-            ConfirmationDialog dialog = new ConfirmationDialog(
-                main_window, primary, secondary, _("_Replace"), "destructive-action"
-            );
-            overwrite = (dialog.run() == Gtk.ResponseType.OK);
-        } catch (GLib.Error err) {
-            // Oh well
-        }
-        return overwrite;
-    }
-
-    private async void write_buffer_to_file(Geary.Memory.Buffer buffer,
-                                            File dest,
-                                            GLib.Cancellable? cancellable) {
-        try {
-            FileOutputStream outs = dest.replace(
-                null, false, FileCreateFlags.REPLACE_DESTINATION, cancellable
-            );
-            yield outs.splice_async(
-                buffer.get_input_stream(),
-                OutputStreamSpliceFlags.CLOSE_SOURCE | OutputStreamSpliceFlags.CLOSE_TARGET,
-                Priority.DEFAULT,
-                cancellable
-            );
-        } catch (GLib.IOError.CANCELLED err) {
-            try {
-                yield dest.delete_async(GLib.Priority.HIGH, null);
-            } catch (GLib.Error err) {
-                // Oh well
-            }
-        } catch (GLib.Error err) {
-            warning(
-                "Error writing buffer \"%s\": %s",
-                dest.get_uri(), err.message
-            );
-            report_problem(new Geary.ProblemReport(err));
-        }
-    }
-
-    private inline Gtk.FileChooserNative new_save_chooser(Gtk.FileChooserAction action) {
-        Gtk.FileChooserNative dialog = new Gtk.FileChooserNative(
-            null,
-            this.main_window,
-            action,
-            Stock._SAVE,
-            Stock._CANCEL
-        );
-        dialog.set_local_only(false);
-        return dialog;
-    }
-
     internal bool close_composition_windows(bool main_window_only = false) {
         Gee.List<ComposerWidget> composers_to_destroy = new Gee.ArrayList<ComposerWidget>();
         bool quit_cancelled = false;
diff --git a/src/client/components/components-attachment-pane.vala 
b/src/client/components/components-attachment-pane.vala
new file mode 100644
index 00000000..3157114d
--- /dev/null
+++ b/src/client/components/components-attachment-pane.vala
@@ -0,0 +1,524 @@
+/*
+ * Copyright 2019 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * Displays the attachment parts for an email.
+ *
+ * This can be used in an editable or non-editable context, the UI
+ * shown will differ slightly based on which is selected.
+ */
+[GtkTemplate (ui = "/org/gnome/Geary/components-attachment-pane.ui")]
+public class Components.AttachmentPane : Gtk.Grid {
+
+
+    private const string GROUP_NAME = "cap";
+    private const string ACTION_OPEN = "open";
+    private const string ACTION_OPEN_SELECTED = "open-selected";
+    private const string ACTION_REMOVE = "remove";
+    private const string ACTION_REMOVE_SELECTED = "remove-selected";
+    private const string ACTION_SAVE = "save";
+    private const string ACTION_SAVE_ALL = "save-all";
+    private const string ACTION_SAVE_SELECTED = "save-selected";
+    private const string ACTION_SELECT_ALL = "select-all";
+
+    private const ActionEntry[] action_entries = {
+        { ACTION_OPEN, on_open, "s" },
+        { ACTION_OPEN_SELECTED, on_open_selected },
+        { ACTION_REMOVE, on_remove, "s" },
+        { ACTION_REMOVE_SELECTED, on_remove_selected },
+        { ACTION_SAVE, on_save, "s" },
+        { ACTION_SAVE_ALL, on_save_all },
+        { ACTION_SAVE_SELECTED, on_save_selected },
+        { ACTION_SELECT_ALL, on_select_all },
+    };
+
+
+    // This exists purely to be able to set key bindings on it.
+    private class FlowBox : Gtk.FlowBox {
+
+        /** Keyboard action to open the currently selected attachments. */
+        [Signal (action=true)]
+        public signal void open_attachments();
+
+        /** Keyboard action to save the currently selected attachments. */
+        [Signal (action=true)]
+        public signal void save_attachments();
+
+        /** Keyboard action to remove the currently selected attachments. */
+        [Signal (action=true)]
+        public signal void remove_attachments();
+
+    }
+
+    // Displays an attachment's icon and details
+    [GtkTemplate (ui = "/org/gnome/Geary/components-attachment-view.ui")]
+    private class View : Gtk.Grid {
+
+
+        private const int ATTACHMENT_ICON_SIZE = 32;
+        private const int ATTACHMENT_PREVIEW_SIZE = 64;
+
+        public Geary.Attachment attachment { get; private set; }
+
+        [GtkChild]
+        private Gtk.Image icon;
+
+        [GtkChild]
+        private Gtk.Label filename;
+
+        [GtkChild]
+        private Gtk.Label description;
+
+        private string gio_content_type;
+
+
+        public View(Geary.Attachment attachment) {
+            this.attachment = attachment;
+            string mime_content_type = attachment.content_type.get_mime_type();
+            this.gio_content_type = ContentType.from_mime_type(
+                mime_content_type
+            );
+
+            string? file_name = attachment.content_filename;
+            string file_desc = GLib.ContentType.get_description(gio_content_type);
+            if (GLib.ContentType.is_unknown(gio_content_type)) {
+                // Translators: This is the file type displayed for
+                // attachments with unknown file types.
+                file_desc = _("Unknown");
+            }
+            string file_size = Files.get_filesize_as_string(
+                attachment.filesize
+            );
+
+            if (Geary.String.is_empty(file_name)) {
+                // XXX Check for unknown types here and try to guess
+                // using attachment data.
+                file_name = file_desc;
+                file_desc = file_size;
+            } else {
+                // Translators: The first argument will be a
+                // description of the document type, the second will
+                // be a human-friendly size string. For example:
+                // Document (100.9MB)
+                file_desc = _("%s (%s)".printf(file_desc, file_size));
+            }
+            this.filename.set_text(file_name);
+            this.description.set_text(file_desc);
+        }
+
+        internal async void load_icon(GLib.Cancellable load_cancelled) {
+            if (load_cancelled.is_cancelled()) {
+                return;
+            }
+
+            Gdk.Pixbuf? pixbuf = null;
+
+            // XXX We need to hook up to GtkWidget::style-set and
+            // reload the icon when the theme changes.
+
+            int window_scale = get_scale_factor();
+            try {
+                // If the file is an image, use it. Otherwise get the
+                // icon for this mime_type.
+                if (this.attachment.content_type.has_media_type("image")) {
+                    // Get a thumbnail for the image.
+                    // TODO Generate and save the thumbnail when
+                    // extracting the attachments rather than when showing
+                    // them in the viewer.
+                    int preview_size = ATTACHMENT_PREVIEW_SIZE * window_scale;
+                    GLib.InputStream stream = yield this.attachment.file.read_async(
+                        Priority.DEFAULT,
+                        load_cancelled
+                    );
+                    pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async(
+                        stream, preview_size, preview_size, true, load_cancelled
+                    );
+                    pixbuf = pixbuf.apply_embedded_orientation();
+                } else {
+                    // Load the icon for this mime type
+                    GLib.Icon icon = GLib.ContentType.get_icon(
+                        this.gio_content_type
+                    );
+                    Gtk.IconTheme theme = Gtk.IconTheme.get_default();
+                    Gtk.IconLookupFlags flags = Gtk.IconLookupFlags.DIR_LTR;
+                    if (get_direction() == Gtk.TextDirection.RTL) {
+                        flags = Gtk.IconLookupFlags.DIR_RTL;
+                    }
+                    Gtk.IconInfo? icon_info = theme.lookup_by_gicon_for_scale(
+                        icon, ATTACHMENT_ICON_SIZE, window_scale, flags
+                    );
+                    if (icon_info != null) {
+                        pixbuf = yield icon_info.load_icon_async(load_cancelled);
+                    }
+                }
+            } catch (GLib.Error error) {
+                debug("Failed to load icon for attachment '%s': %s",
+                      this.attachment.file.get_path(),
+                      error.message);
+            }
+
+            if (pixbuf != null) {
+                Cairo.Surface surface = Gdk.cairo_surface_create_from_pixbuf(
+                    pixbuf, window_scale, get_window()
+                );
+                this.icon.set_from_surface(surface);
+            }
+        }
+
+    }
+
+
+    static construct {
+        // Set up custom keybindings
+        unowned Gtk.BindingSet bindings = Gtk.BindingSet.by_class(
+            (ObjectClass) typeof(FlowBox).class_ref()
+        );
+
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.O, Gdk.ModifierType.CONTROL_MASK, "open-attachments", 0
+        );
+
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.S, Gdk.ModifierType.CONTROL_MASK, "save-attachments", 0
+        );
+
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.BackSpace, 0, "remove-attachments", 0
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.Delete, 0, "remove-attachments", 0
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.KP_Delete, 0, "remove-attachments", 0
+        );
+    }
+
+
+    /** Determines if this pane's contents can be modified. */
+    public bool edit_mode { get; private set; }
+
+    private Gee.List<Geary.Attachment> attachments =
+         new Gee.LinkedList<Geary.Attachment>();
+
+    private Application.AttachmentManager manager;
+
+    private GLib.SimpleActionGroup actions = new GLib.SimpleActionGroup();
+
+    [GtkChild]
+    private Gtk.Grid attachments_container;
+
+    [GtkChild]
+    private Gtk.Button save_button;
+
+    [GtkChild]
+    private Gtk.Button remove_button;
+
+    private FlowBox attachments_view;
+
+
+    public AttachmentPane(bool edit_mode,
+                          Application.AttachmentManager manager) {
+        this.edit_mode = edit_mode;
+        if (edit_mode) {
+            save_button.hide();
+        } else {
+            remove_button.hide();
+        }
+
+        this.manager = manager;
+
+        this.attachments_view = new FlowBox();
+        this.attachments_view.open_attachments.connect(on_open_selected);
+        this.attachments_view.remove_attachments.connect(on_remove_selected);
+        this.attachments_view.save_attachments.connect(on_save_selected);
+        this.attachments_view.child_activated.connect(on_child_activated);
+        this.attachments_view.selected_children_changed.connect(on_selected_changed);
+        this.attachments_view.button_press_event.connect(on_attachment_button_press);
+               this.attachments_view.popup_menu.connect(on_attachment_popup_menu);
+        this.attachments_view.activate_on_single_click = false;
+        this.attachments_view.max_children_per_line = 3;
+        this.attachments_view.column_spacing = 6;
+        this.attachments_view.row_spacing = 6;
+        this.attachments_view.selection_mode = Gtk.SelectionMode.MULTIPLE;
+        this.attachments_view.hexpand = true;
+        this.attachments_view.show();
+        this.attachments_container.add(this.attachments_view);
+
+        this.actions.add_action_entries(action_entries, this);
+        insert_action_group(GROUP_NAME, this.actions);
+    }
+
+    public void add_attachment(Geary.Attachment attachment,
+                               GLib.Cancellable? cancellable) {
+        View view = new View(attachment);
+        this.attachments_view.add(view);
+        this.attachments.add(attachment);
+        view.load_icon.begin(cancellable);
+
+        update_actions();
+    }
+
+    public void open_attachment(Geary.Attachment attachment) {
+        open_attachments(Geary.Collection.single(attachment));
+    }
+
+    public void save_attachment(Geary.Attachment attachment) {
+        this.manager.save_attachment.begin(
+            attachment,
+            null,
+            null // No cancellable for the moment, need UI for it
+        );
+    }
+
+    public void remove_attachment(Geary.Attachment attachment) {
+        this.attachments.remove(attachment);
+        this.attachments_view.foreach(child => {
+                Gtk.FlowBoxChild flow_child = (Gtk.FlowBoxChild) child;
+                if (((View) flow_child.get_child()).attachment == attachment) {
+                    this.attachments_view.remove(child);
+                }
+            });
+    }
+
+    public bool save_all() {
+        bool ret = false;
+        if (!this.attachments.is_empty) {
+            var all = new Gee.ArrayList<Geary.Attachment>();
+            all.add_all(this.attachments);
+            this.manager.save_attachments.begin(
+                all,
+                null // No cancellable for the moment, need UI for it
+            );
+        }
+        return ret;
+    }
+
+    private Geary.Attachment? get_attachment(GLib.Variant param) {
+        Geary.Attachment? ret = null;
+        string path = (string) param;
+        foreach (var attachment in this.attachments) {
+            if (attachment.file.get_path() == path) {
+                ret = attachment;
+                break;
+            }
+        }
+        return ret;
+    }
+
+    private Gee.Collection<Geary.Attachment> get_selected_attachments() {
+        var selected = new Gee.LinkedList<Geary.Attachment>();
+        this.attachments_view.selected_foreach((box, child) => {
+                selected.add(
+                    ((View) child.get_child()).attachment
+                );
+            });
+        return selected;
+    }
+
+    private bool open_selected() {
+        bool ret = false;
+        var selected = get_selected_attachments();
+        if (!selected.is_empty) {
+            open_attachments(selected);
+            ret = true;
+        }
+        return ret;
+    }
+
+    private bool save_selected() {
+        bool ret = false;
+        var selected = get_selected_attachments();
+        if (!this.edit_mode && !selected.is_empty) {
+            this.manager.save_attachments.begin(
+                selected,
+                null // No cancellable for the moment, need UI for it
+            );
+            ret = true;
+        }
+        return ret;
+    }
+
+    private bool remove_selected() {
+        bool ret = false;
+        GLib.List<unowned Gtk.FlowBoxChild> children =
+            this.attachments_view.get_selected_children();
+        if (this.edit_mode && children.length() > 0) {
+            children.foreach(child => {
+                    this.attachments_view.remove(child);
+                    this.attachments.remove(
+                        ((View) child.get_child()).attachment
+                    );
+                });
+            ret = true;
+        }
+        return ret;
+    }
+
+    private void update_actions() {
+        uint len = this.attachments_view.get_selected_children().length();
+        bool not_empty = len > 0;
+
+        set_action_enabled(ACTION_OPEN_SELECTED, not_empty);
+        set_action_enabled(ACTION_REMOVE_SELECTED, not_empty && this.edit_mode);
+        set_action_enabled(ACTION_SAVE_SELECTED, not_empty && !this.edit_mode);
+        set_action_enabled(ACTION_SELECT_ALL, len < this.attachments.size);
+    }
+
+    private void open_attachments(Gee.Collection<Geary.Attachment> attachments) {
+        MainWindow? main = this.get_toplevel() as MainWindow;
+        if (main != null) {
+            GearyApplication app = main.application;
+            bool confirmed = true;
+            if (app.config.ask_open_attachment) {
+                QuestionDialog ask_to_open = new QuestionDialog.with_checkbox(
+                    main,
+                    _("Are you sure you want to open these attachments?"),
+                    _("Attachments may cause damage to your system if opened.  Only open files from trusted 
sources."),
+                    Stock._OPEN_BUTTON, Stock._CANCEL, _("Don’t _ask me again"), false
+                );
+                if (ask_to_open.run() == Gtk.ResponseType.OK) {
+                    app.config.ask_open_attachment = !ask_to_open.is_checked;
+                } else {
+                    confirmed = false;
+                }
+            }
+
+            if (confirmed) {
+                foreach (var attachment in attachments) {
+                    app.show_uri.begin(attachment.file.get_uri());
+                }
+            }
+        }
+    }
+
+    private void set_action_enabled(string name, bool enabled) {
+        SimpleAction? action = this.actions.lookup_action(name) as SimpleAction;
+        if (action != null) {
+            action.set_enabled(enabled);
+        }
+    }
+
+    private void show_popup(View view, Gdk.EventButton? event) {
+        Gtk.Builder builder = new Gtk.Builder.from_resource(
+            "/org/gnome/Geary/components-attachment-pane-menus.ui"
+        );
+        var targets = new Gee.HashMap<string,GLib.Variant>();
+        GLib.Variant target = view.attachment.file.get_path();
+        targets[ACTION_OPEN] = target;
+        targets[ACTION_REMOVE] = target;
+        targets[ACTION_SAVE] = target;
+        GLib.Menu model = Util.Gtk.copy_menu_with_targets(
+            (GLib.Menu) builder.get_object("attachments_menu"),
+            GROUP_NAME,
+            targets
+        );
+        Gtk.Menu menu = new Gtk.Menu.from_model(model);
+        menu.attach_to_widget(view, null);
+        if (event != null) {
+            menu.popup_at_pointer(event);
+        } else {
+            menu.popup_at_widget(view, CENTER, SOUTH, null);
+        }
+    }
+
+    private void beep() {
+        Gtk.Widget? toplevel = get_toplevel();
+        if (toplevel == null) {
+            Gdk.Window? window = toplevel.get_window();
+            if (window != null) {
+                window.beep();
+            }
+        }
+    }
+
+    private void on_open(GLib.SimpleAction action, GLib.Variant? param) {
+        var target = get_attachment(param);
+        if (target != null) {
+            open_attachment(target);
+        }
+    }
+
+    private void on_open_selected() {
+        if (!open_selected()) {
+            beep();
+        }
+    }
+
+    private void on_save(GLib.SimpleAction action, GLib.Variant? param) {
+        var target = get_attachment(param);
+        if (target != null) {
+            save_attachment(target);
+        }
+    }
+
+    private void on_save_all() {
+        debug("save all!");
+        if (!save_all()) {
+            beep();
+        }
+    }
+
+    private void on_save_selected() {
+        if (!save_selected()) {
+            beep();
+        }
+    }
+
+    private void on_remove(GLib.SimpleAction action, GLib.Variant? param) {
+        var target = get_attachment(param);
+        if (target != null) {
+            remove_attachment(target);
+        }
+    }
+
+    private void on_remove_selected() {
+        if (!remove_selected()) {
+            beep();
+        }
+    }
+
+    private void on_select_all() {
+        this.attachments_view.select_all();
+    }
+
+    private void on_child_activated() {
+        open_selected();
+    }
+
+    private void on_selected_changed() {
+        update_actions();
+    }
+
+       private bool on_attachment_popup_menu(Gtk.Widget widget) {
+        bool ret = Gdk.EVENT_PROPAGATE;
+        Gtk.Window parent = get_toplevel() as Gtk.Window;
+        if (parent != null) {
+            Gtk.FlowBoxChild? focus = parent.get_focus() as Gtk.FlowBoxChild;
+            if (focus != null && focus.parent == this.attachments_view) {
+                show_popup((View) focus.get_child(), null);
+                ret = Gdk.EVENT_STOP;
+            }
+        }
+        return ret;
+       }
+
+       private bool on_attachment_button_press(Gtk.Widget widget,
+                                            Gdk.EventButton event) {
+        bool ret = Gdk.EVENT_PROPAGATE;
+               if (event.triggers_context_menu()) {
+            Gtk.FlowBoxChild? child = this.attachments_view.get_child_at_pos(
+                (int) event.x,
+                (int) event.y
+            );
+            if (child != null) {
+                show_popup((View) child.get_child(), event);
+                ret = Gdk.EVENT_STOP;
+            }
+               }
+        return ret;
+       }
+}
diff --git a/src/client/components/main-window.vala b/src/client/components/main-window.vala
index 5e248814..f49ad80e 100644
--- a/src/client/components/main-window.vala
+++ b/src/client/components/main-window.vala
@@ -171,6 +171,9 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
         get; private set; default = null;
     }
 
+    /** The attachment manager for this window. */
+    public Application.AttachmentManager attachments { get; private set; }
+
     /** Determines if a composer is currently open in this window. */
     public bool has_composer {
         get {
@@ -268,6 +271,8 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
         update_command_actions();
         update_conversation_actions(NONE);
 
+        this.attachments = new Application.AttachmentManager(this);
+
         this.application.engine.account_available.connect(on_account_available);
         this.application.engine.account_unavailable.connect(on_account_unavailable);
 
@@ -1398,9 +1403,6 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
         view.reply_all_message.connect(on_reply_all_message);
         view.reply_to_message.connect(on_reply_to_message);
         view.edit_draft.connect(on_edit_draft);
-
-        view.attachments_activated.connect(on_attachments_activated);
-        view.save_attachments.connect(on_save_attachments);
         view.view_source.connect(on_view_source);
 
         Geary.App.Conversation conversation = this.conversation_viewer.current_list.conversation;
@@ -1414,12 +1416,6 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
         view.delete_message.connect(on_delete_message);
         view.set_folder_actions_enabled(supports_trash, supports_delete);
         this.on_shift_key.connect(view.shift_key_changed);
-
-        foreach (ConversationMessage msg_view in view) {
-            msg_view.save_image.connect((url, alt_text, buf) => {
-                    on_save_image_extended(view, url, alt_text, buf);
-                });
-        }
     }
 
     // Window-level action callbacks
@@ -1854,42 +1850,6 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
         }
     }
 
-    private void on_attachments_activated(Gee.Collection<Geary.Attachment> attachments) {
-        if (this.application.config.ask_open_attachment) {
-            QuestionDialog ask_to_open = new QuestionDialog.with_checkbox(
-                this,
-                _("Are you sure you want to open these attachments?"),
-                _("Attachments may cause damage to your system if opened.  Only open files from trusted 
sources."),
-                Stock._OPEN_BUTTON, Stock._CANCEL, _("Don’t _ask me again"), false);
-            if (ask_to_open.run() != Gtk.ResponseType.OK) {
-                return;
-            }
-            // only save checkbox state if OK was selected
-            this.application.config.ask_open_attachment = !ask_to_open.is_checked;
-        }
-
-        foreach (Geary.Attachment attachment in attachments) {
-            this.application.show_uri.begin(attachment.file.get_uri());
-        }
-    }
-
-    private void on_save_attachments(Gee.Collection<Geary.Attachment> attachments) {
-        if (this.selected_account != null) {
-            if (attachments.size == 1) {
-                this.application.controller.save_attachment_to_file.begin(
-                    this.selected_account,
-                    attachments.to_array()[0],
-                    null
-                );
-            } else {
-                this.application.controller.save_attachments_to_file.begin(
-                    this.selected_account,
-                    attachments
-                );
-            }
-        }
-    }
-
     private void on_view_source(ConversationEmail email_view) {
         string source = (email_view.email.header.buffer.to_string() +
                          email_view.email.body.buffer.to_string());
@@ -1916,17 +1876,6 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
         }
     }
 
-    private void on_save_image_extended(ConversationEmail view,
-                                        string url,
-                                        string? alt_text,
-                                        Geary.Memory.Buffer resource_buf) {
-        if (this.selected_account != null) {
-            this.application.controller.save_image_extended.begin(
-                this.selected_account, view, url, alt_text, resource_buf
-            );
-        }
-    }
-
     private void on_trash_message(ConversationEmail target_view) {
         Geary.Folder? source = this.selected_folder;
         if (source != null) {
diff --git a/src/client/conversation-viewer/conversation-email.vala 
b/src/client/conversation-viewer/conversation-email.vala
index 87a84b28..a349bebf 100644
--- a/src/client/conversation-viewer/conversation-email.vala
+++ b/src/client/conversation-viewer/conversation-email.vala
@@ -127,131 +127,16 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     }
 
 
-    // Displays an attachment's icon and details
-    [GtkTemplate (ui = "/org/gnome/Geary/conversation-email-attachment-view.ui")]
-    private class AttachmentView : Gtk.Grid {
-
-        public Geary.Attachment attachment { get; private set; }
-
-        [GtkChild]
-        private Gtk.Image icon;
-
-        [GtkChild]
-        private Gtk.Label filename;
-
-        [GtkChild]
-        private Gtk.Label description;
-
-        private string gio_content_type;
-
-        public AttachmentView(Geary.Attachment attachment) {
-            this.attachment = attachment;
-            string mime_content_type = attachment.content_type.get_mime_type();
-            this.gio_content_type = ContentType.from_mime_type(
-                mime_content_type
-            );
-
-            string? file_name = attachment.content_filename;
-            string file_desc = ContentType.get_description(gio_content_type);
-            if (ContentType.is_unknown(gio_content_type)) {
-                // Translators: This is the file type displayed for
-                // attachments with unknown file types.
-                file_desc = _("Unknown");
-            }
-            string file_size = Files.get_filesize_as_string(attachment.filesize);
-
-            if (Geary.String.is_empty(file_name)) {
-                // XXX Check for unknown types here and try to guess
-                // using attachment data.
-                file_name = file_desc;
-                file_desc = file_size;
-            } else {
-                // Translators: The first argument will be a
-                // description of the document type, the second will
-                // be a human-friendly size string. For example:
-                // Document (100.9MB)
-                file_desc = _("%s (%s)".printf(file_desc, file_size));
-            }
-            this.filename.set_text(file_name);
-            this.description.set_text(file_desc);
-        }
-
-        internal async void load_icon(Cancellable load_cancelled) {
-            if (load_cancelled.is_cancelled()) {
-                return;
-            }
-
-            Gdk.Pixbuf? pixbuf = null;
-
-            // XXX We need to hook up to GtkWidget::style-set and
-            // reload the icon when the theme changes.
-
-            int window_scale = get_scale_factor();
-            try {
-                // If the file is an image, use it. Otherwise get the
-                // icon for this mime_type.
-                if (this.attachment.content_type.has_media_type("image")) {
-                    // Get a thumbnail for the image.
-                    // TODO Generate and save the thumbnail when
-                    // extracting the attachments rather than when showing
-                    // them in the viewer.
-                    int preview_size = ATTACHMENT_PREVIEW_SIZE * window_scale;
-                    InputStream stream = yield this.attachment.file.read_async(
-                        Priority.DEFAULT,
-                        load_cancelled
-                    );
-                    pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async(
-                        stream, preview_size, preview_size, true, load_cancelled
-                    );
-                    pixbuf = pixbuf.apply_embedded_orientation();
-                } else {
-                    // Load the icon for this mime type
-                    Icon icon = ContentType.get_icon(this.gio_content_type);
-                    Gtk.IconTheme theme = Gtk.IconTheme.get_default();
-                    Gtk.IconLookupFlags flags = Gtk.IconLookupFlags.DIR_LTR;
-                    if (get_direction() == Gtk.TextDirection.RTL) {
-                        flags = Gtk.IconLookupFlags.DIR_RTL;
-                    }
-                    Gtk.IconInfo? icon_info = theme.lookup_by_gicon_for_scale(
-                        icon, ATTACHMENT_ICON_SIZE, window_scale, flags
-                    );
-                    if (icon_info != null) {
-                        pixbuf = yield icon_info.load_icon_async(load_cancelled);
-                    }
-                }
-            } catch (Error error) {
-                debug("Failed to load icon for attachment '%s': %s",
-                      this.attachment.file.get_path(),
-                      error.message);
-            }
-
-            if (pixbuf != null) {
-                Cairo.Surface surface = Gdk.cairo_surface_create_from_pixbuf(
-                    pixbuf, window_scale, get_window()
-                );
-                this.icon.set_from_surface(surface);
-            }
-        }
-
-    }
-
-
-    private const int ATTACHMENT_ICON_SIZE = 32;
-    private const int ATTACHMENT_PREVIEW_SIZE = 64;
-
     private const string ACTION_FORWARD = "forward";
     private const string ACTION_MARK_READ = "mark_read";
     private const string ACTION_MARK_UNREAD = "mark_unread";
     private const string ACTION_MARK_UNREAD_DOWN = "mark_unread_down";
     private const string ACTION_TRASH_MESSAGE = "trash_msg";
     private const string ACTION_DELETE_MESSAGE = "delete_msg";
-    private const string ACTION_OPEN_ATTACHMENTS = "open_attachments";
     private const string ACTION_PRINT = "print";
     private const string ACTION_REPLY_SENDER = "reply_sender";
     private const string ACTION_REPLY_ALL = "reply_all";
-    private const string ACTION_SAVE_ATTACHMENTS = "save_attachments";
     private const string ACTION_SAVE_ALL_ATTACHMENTS = "save_all_attachments";
-    private const string ACTION_SELECT_ALL_ATTACHMENTS = "select_all_attachments";
     private const string ACTION_STAR = "star";
     private const string ACTION_UNSTAR = "unstar";
     private const string ACTION_VIEW_SOURCE = "view_source";
@@ -354,16 +239,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     [GtkChild]
     private Gtk.Grid sub_messages;
 
-    [GtkChild]
-    private Gtk.Grid attachments;
-
-    [GtkChild]
-    private Gtk.FlowBox attachments_view;
-
-    [GtkChild]
-    private Gtk.Button select_all_attachments;
-
-    private Gtk.Menu attachments_menu;
+    private Components.AttachmentPane? attachments_pane = null;
 
     private Menu email_menu;
     private Menu email_menu_model;
@@ -400,16 +276,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     /** Fired when the user clicks "delete" in the message menu. */
     public signal void delete_message();
 
-    /** Fired when the user activates an attachment. */
-    public signal void attachments_activated(
-        Gee.Collection<Geary.Attachment> attachments
-    );
-
-    /** Fired when the user saves an attachment. */
-    public signal void save_attachments(
-        Gee.Collection<Geary.Attachment> attachments
-    );
-
     /** Fired the edit draft button is clicked. */
     public signal void edit_draft();
 
@@ -472,23 +338,14 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         add_action(ACTION_DELETE_MESSAGE).activate.connect(() => {
                 delete_message();
             });
-        add_action(ACTION_OPEN_ATTACHMENTS, false).activate.connect(() => {
-                attachments_activated(get_selected_attachments());
-            });
         add_action(ACTION_REPLY_ALL).activate.connect(() => {
                 reply_all_message();
             });
         add_action(ACTION_REPLY_SENDER).activate.connect(() => {
                 reply_to_message();
             });
-        add_action(ACTION_SAVE_ATTACHMENTS, false).activate.connect(() => {
-                save_attachments(get_selected_attachments());
-            });
         add_action(ACTION_SAVE_ALL_ATTACHMENTS).activate.connect(() => {
-                save_attachments(this.displayed_attachments);
-            });
-        add_action(ACTION_SELECT_ALL_ATTACHMENTS, false).activate.connect(() => {
-                this.attachments_view.select_all();
+                this.attachments_pane.save_all();
             });
         add_action(ACTION_STAR).activate.connect(() => {
                 mark_email(Geary.EmailFlags.FLAGGED, null);
@@ -526,11 +383,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         this.email_menubutton.set_sensitive(false);
         this.email_menubutton.toggled.connect(this.on_email_menu);
 
-        this.attachments_menu = new Gtk.Menu.from_model(
-            (MenuModel) builder.get_object("attachments_menu")
-        );
-        this.attachments_menu.attach_to_widget(this, null);
-
         this.primary_message.infobars.add(this.draft_infobar);
         if (is_draft) {
             this.draft_infobar.show();
@@ -760,6 +612,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         view.internal_link_activated.connect((y) => {
                 internal_link_activated(y);
             });
+        view.save_image.connect(on_save_image);
         view.web_view.internal_resource_loaded.connect(on_resource_loaded);
         view.web_view.content_loaded.connect(on_content_loaded);
         view.web_view.selection_changed.connect((has_selection) => {
@@ -904,32 +757,22 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     private void update_displayed_attachments() {
         bool has_attachments = !this.displayed_attachments.is_empty;
         this.attachments_button.set_visible(has_attachments);
-        if (has_attachments) {
-            this.primary_message.body_container.add(this.attachments);
+        MainWindow? main = get_toplevel() as MainWindow;
 
-            if (this.displayed_attachments.size > 1) {
-                this.select_all_attachments.show();
-                set_action_enabled(ACTION_SELECT_ALL_ATTACHMENTS, true);
-            }
+        if (has_attachments && main != null) {
+            this.attachments_pane = new Components.AttachmentPane(
+                false, main.attachments
+            );
+            this.primary_message.body_container.add(this.attachments_pane);
 
-            foreach (Geary.Attachment attachment in this.displayed_attachments) {
-                AttachmentView view = new AttachmentView(attachment);
-                this.attachments_view.add(view);
-                view.load_icon.begin(this.load_cancellable);
+            foreach (var attachment in this.displayed_attachments) {
+                this.attachments_pane.add_attachment(
+                    attachment, this.load_cancellable
+                );
             }
         }
     }
 
-    internal Gee.Collection<Geary.Attachment> get_selected_attachments() {
-        Gee.LinkedList<Geary.Attachment> selected =
-            new Gee.LinkedList<Geary.Attachment>();
-        foreach (Gtk.FlowBoxChild child in
-                 this.attachments_view.get_selected_children()) {
-            selected.add(((AttachmentView) child.get_child()).attachment);
-        }
-        return selected;
-    }
-
     private void handle_load_failure(GLib.Error err) {
         load_error(err);
         this.message_body_state = FAILED;
@@ -1045,6 +888,44 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         }
     }
 
+    private void on_save_image(string uri,
+                               string? alt_text,
+                               Geary.Memory.Buffer? content) {
+        MainWindow? main = get_toplevel() as MainWindow;
+        if (main != null) {
+            if (uri.has_prefix(ClientWebView.CID_URL_PREFIX)) {
+                string cid = uri.substring(ClientWebView.CID_URL_PREFIX.length);
+                try {
+                    Geary.Attachment attachment = this.email.get_attachment_by_content_id(
+                        cid
+                    );
+                    main.attachments.save_attachment.begin(
+                        attachment,
+                        alt_text,
+                        null // XXX no cancellable yet, need UI for it
+                    );
+                } catch (GLib.Error err) {
+                    debug("Could not get attachment \"%s\": %s", cid, err.message);
+                }
+            } else if (content != null) {
+                GLib.File source = GLib.File.new_for_uri(uri);
+                // Querying the URL-based file for the display name
+                // results in it being looked up, so just get the basename
+                // from it directly. GIO seems to decode any %-encoded
+                // chars anyway.
+                string? display_name = source.get_basename();
+                if (Geary.String.is_empty_or_whitespace(display_name)) {
+                    display_name = Application.AttachmentManager.untitled_file_name;
+                }
+                main.attachments.save_buffer.begin(
+                    display_name,
+                    content,
+                    null // XXX no cancellable yet, need UI for it
+                );
+            }
+        }
+    }
+
     private void on_resource_loaded(string id) {
         Gee.Iterator<Geary.Attachment> displayed =
             this.displayed_attachments.iterator();
@@ -1078,26 +959,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         }
     }
 
-    [GtkCallback]
-    private void on_attachments_child_activated(Gtk.FlowBox view,
-                                                Gtk.FlowBoxChild child) {
-        attachments_activated(
-            Geary.iterate<Geary.Attachment>(
-                ((AttachmentView) child.get_child()).attachment
-            ).to_array_list()
-        );
-    }
-
-    [GtkCallback]
-    private void on_attachments_selected_changed(Gtk.FlowBox view) {
-        uint len = view.get_selected_children().length();
-        bool not_empty = len > 0;
-        set_action_enabled(ACTION_OPEN_ATTACHMENTS, not_empty);
-        set_action_enabled(ACTION_SAVE_ATTACHMENTS, not_empty);
-        set_action_enabled(ACTION_SELECT_ALL_ATTACHMENTS,
-                           len < this.displayed_attachments.size);
-    }
-
     private void on_service_status_change() {
         if (this.message_body_state == FAILED &&
             !this.load_cancellable.is_cancelled() &&
diff --git a/src/client/conversation-viewer/conversation-message.vala 
b/src/client/conversation-viewer/conversation-message.vala
index fb0cf1d0..1ccd7a7f 100644
--- a/src/client/conversation-viewer/conversation-message.vala
+++ b/src/client/conversation-viewer/conversation-message.vala
@@ -342,7 +342,9 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
     public signal void flag_remote_images();
 
     /** Fired when the user saves an inline displayed image. */
-    public signal void save_image(string? uri, string? alt_text, Geary.Memory.Buffer buffer);
+    public signal void save_image(
+        string uri, string? alt_text, Geary.Memory.Buffer? buffer
+    );
 
 
     /**
@@ -1232,27 +1234,35 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
     }
 
     private void on_save_image(Variant? param) {
-        string cid_url = param.get_child_value(0).get_string();
-
+        string uri = (string) param.get_child_value(0);
         string? alt_text = null;
         Variant? alt_maybe = param.get_child_value(1).get_maybe();
         if (alt_maybe != null) {
-            alt_text = alt_maybe.get_string();
+            alt_text = (string) alt_maybe;
+        }
+
+        if (uri.has_prefix(ClientWebView.CID_URL_PREFIX)) {
+            // We can get the data directly from the attachment, so
+            // don't bother getting it from the web view
+            save_image(uri, alt_text, null);
+        } else {
+            WebKit.WebResource response = this.resources.get(uri);
+            response.get_data.begin(null, (obj, res) => {
+                    try {
+                        uint8[] data = response.get_data.end(res);
+                        save_image(
+                            uri,
+                            alt_text,
+                            new Geary.Memory.ByteBuffer(data, data.length)
+                        );
+                    } catch (GLib.Error err) {
+                        debug(
+                            "Failed to get image data from web view: %s",
+                            err.message
+                        );
+                    }
+                });
         }
-        WebKit.WebResource response = this.resources.get(cid_url);
-        response.get_data.begin(null, (obj, res) => {
-                try {
-                    uint8[] data = response.get_data.end(res);
-                    save_image(response.get_uri(),
-                               alt_text,
-                               new Geary.Memory.ByteBuffer(data, data.length));
-                } catch (Error err) {
-                    debug(
-                        "Failed to get image data from web view: %s",
-                        err.message
-                    );
-                }
-            });
     }
 
     private void on_link_activated(GLib.Variant? param) {
diff --git a/src/client/meson.build b/src/client/meson.build
index e306071c..cf64427b 100644
--- a/src/client/meson.build
+++ b/src/client/meson.build
@@ -1,5 +1,6 @@
 # Geary client
 geary_client_vala_sources = files(
+  'application/application-attachment-manager.vala',
   'application/application-avatar-store.vala',
   'application/application-certificate-manager.vala',
   'application/application-command.vala',
@@ -25,6 +26,7 @@ geary_client_vala_sources = files(
   'accounts/accounts-manager.vala',
 
   'components/client-web-view.vala',
+  'components/components-attachment-pane.vala',
   'components/components-inspector.vala',
   'components/components-in-app-notification.vala',
   'components/components-inspector-error-view.vala',
diff --git a/src/client/util/util-gtk.vala b/src/client/util/util-gtk.vala
index bcc6aa33..37674761 100644
--- a/src/client/util/util-gtk.vala
+++ b/src/client/util/util-gtk.vala
@@ -1,7 +1,9 @@
-/* Copyright 2016 Software Freedom Conservancy Inc.
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2019 Michael Gratton <mike vee net>
  *
  * This software is licensed under the GNU Lesser General Public License
- * (version 2.1 or later).  See the COPYING file in this distribution.
+ * (version 2.1 or later). See the COPYING file in this distribution.
  */
 
 namespace GtkUtil {
@@ -80,3 +82,48 @@ public inline int get_border_box_height(Gtk.Widget widget) {
 }
 
 }
+
+namespace Util.Gtk {
+
+    /** Copies a GLib menu, setting targets for the given actions. */
+    public GLib.Menu copy_menu_with_targets(GLib.Menu template,
+                                            string group,
+                                            Gee.Map<string,GLib.Variant> targets) {
+        string group_prefix = group + ".";
+        GLib.Menu copy = new GLib.Menu();
+        for (int i = 0; i < template.get_n_items(); i++) {
+            GLib.MenuItem item = new GLib.MenuItem.from_model(template, i);
+            GLib.Menu? section = (GLib.Menu) item.get_link(
+                GLib.Menu.LINK_SECTION
+            );
+            GLib.Menu? submenu = (GLib.Menu) item.get_link(
+                GLib.Menu.LINK_SUBMENU
+            );
+
+            if (section != null) {
+                item.set_section(
+                    copy_menu_with_targets(section, group, targets)
+                );
+            } else if (submenu != null) {
+                item.set_submenu(
+                    copy_menu_with_targets(submenu, group, targets)
+                );
+            } else {
+                string? action = (string) item.get_attribute_value(
+                    GLib.Menu.ATTRIBUTE_ACTION, GLib.VariantType.STRING
+                );
+                if (action != null && action.has_prefix(group_prefix)) {
+                    GLib.Variant? target = targets.get(
+                        action.substring(group_prefix.length)
+                    );
+                    if (target != null) {
+                        item.set_action_and_target_value(action, target);
+                    }
+                }
+            }
+            copy.append_item(item);
+        }
+        return copy;
+    }
+
+}
diff --git a/ui/components-attachment-pane-menus.ui b/ui/components-attachment-pane-menus.ui
new file mode 100644
index 00000000..33f3a9e0
--- /dev/null
+++ b/ui/components-attachment-pane-menus.ui
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<interface>
+  <menu id="attachments_menu">
+    <section>
+      <item>
+        <attribute name="label" translatable="yes">_Open</attribute>
+        <attribute name="action">cap.open</attribute>
+      </item>
+      <item>
+        <attribute name="label" translatable="yes">_Save</attribute>
+        <attribute name="action">cap.save</attribute>
+      </item>
+    </section>
+    <section>
+      <item>
+        <attribute name="label" translatable="yes">Save _All</attribute>
+        <attribute name="action">cap.save-all</attribute>
+      </item>
+    </section>
+  </menu>
+</interface>
diff --git a/ui/components-attachment-pane.ui b/ui/components-attachment-pane.ui
new file mode 100644
index 00000000..206ac3ce
--- /dev/null
+++ b/ui/components-attachment-pane.ui
@@ -0,0 +1,151 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.22.1 -->
+<interface>
+  <requires lib="gtk+" version="3.20"/>
+  <template class="ComponentsAttachmentPane" parent="GtkGrid">
+    <property name="name">box</property>
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="orientation">vertical</property>
+    <child>
+      <object class="GtkSeparator">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkActionBar" id="attachments_actions">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <child>
+          <object class="GtkButton" id="select_all_button">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">False</property>
+            <property name="tooltip_text" translatable="yes">Select all attachments</property>
+            <property name="action_name">cap.select-all</property>
+            <child>
+              <object class="GtkImage">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">edit-select-all-symbolic</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="remove_button">
+            <property name="can_focus">True</property>
+            <property name="receives_default">False</property>
+            <property name="tooltip_text" translatable="yes">Select all attachments</property>
+            <property name="action_name">cap.remove-selected</property>
+            <child>
+              <object class="GtkImage">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">list-remove-symbolic</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="save_button">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">False</property>
+            <property name="tooltip_text" translatable="yes">Save selected attachments</property>
+            <property name="action_name">cap.save-selected</property>
+            <child>
+              <object class="GtkImage">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">document-save-symbolic</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="open_button">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">False</property>
+            <property name="tooltip_text" translatable="yes">Open selected attachments</property>
+            <property name="action_name">cap.open-selected</property>
+            <child>
+              <object class="GtkImage">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">document-open-symbolic</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <style>
+          <class name="background"/>
+        </style>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">2</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkGrid" id="attachments_container">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="orientation">vertical</property>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+      </packing>
+    </child>
+    <style>
+      <class name="view"/>
+      <class name="geary-attachment-pane"/>
+    </style>
+  </template>
+</interface>
diff --git a/ui/conversation-email-attachment-view.ui b/ui/components-attachment-view.ui
similarity index 94%
rename from ui/conversation-email-attachment-view.ui
rename to ui/components-attachment-view.ui
index 16b5f308..85257235 100644
--- a/ui/conversation-email-attachment-view.ui
+++ b/ui/components-attachment-view.ui
@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<!-- Generated with glade 3.20.0 -->
+<!-- Generated with glade 3.22.1 -->
 <interface>
   <requires lib="gtk+" version="3.14"/>
-  <template class="ConversationEmailAttachmentView" parent="GtkGrid">
+  <template class="ComponentsAttachmentPaneView" parent="GtkGrid">
     <property name="visible">True</property>
     <property name="can_focus">False</property>
     <property name="column_spacing">6</property>
diff --git a/ui/conversation-email-menus.ui b/ui/conversation-email-menus.ui
index 098e1f2a..ecf3176d 100644
--- a/ui/conversation-email-menus.ui
+++ b/ui/conversation-email-menus.ui
@@ -71,22 +71,4 @@
       </item>
     </section>
   </menu>
-  <menu id="attachments_menu">
-    <section>
-      <item>
-        <attribute name="label" translatable="yes">_Open</attribute>
-        <attribute name="action">eml.open_attachments</attribute>
-      </item>
-    </section>
-    <section>
-      <item>
-        <attribute name="label" translatable="yes">_Save</attribute>
-        <attribute name="action">eml.save_attachments</attribute>
-      </item>
-      <item>
-        <attribute name="label" translatable="yes">_Save All</attribute>
-        <attribute name="action">eml.save_all_attachments</attribute>
-      </item>
-    </section>
-  </menu>
 </interface>
diff --git a/ui/conversation-email.ui b/ui/conversation-email.ui
index 080d4f6e..156f1b0f 100644
--- a/ui/conversation-email.ui
+++ b/ui/conversation-email.ui
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<!-- Generated with glade 3.20.0 -->
+<!-- Generated with glade 3.22.1 -->
 <interface>
   <requires lib="gtk+" version="3.14"/>
   <template class="ConversationEmail" parent="GtkBox">
@@ -109,122 +109,6 @@
       </packing>
     </child>
   </object>
-  <object class="GtkGrid" id="attachments">
-    <property name="name">box</property>
-    <property name="visible">True</property>
-    <property name="can_focus">False</property>
-    <property name="orientation">vertical</property>
-    <child>
-      <object class="GtkSeparator">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-      </object>
-      <packing>
-        <property name="left_attach">0</property>
-        <property name="top_attach">0</property>
-      </packing>
-    </child>
-    <child>
-      <object class="GtkFlowBox" id="attachments_view">
-        <property name="visible">True</property>
-        <property name="can_focus">True</property>
-        <property name="margin_left">6</property>
-        <property name="margin_right">6</property>
-        <property name="margin_top">6</property>
-        <property name="margin_bottom">6</property>
-        <property name="hexpand">True</property>
-        <property name="homogeneous">True</property>
-        <property name="column_spacing">6</property>
-        <property name="row_spacing">6</property>
-        <property name="max_children_per_line">4</property>
-        <property name="selection_mode">multiple</property>
-        <property name="activate_on_single_click">False</property>
-        <signal name="child-activated" handler="on_attachments_child_activated" swapped="no"/>
-        <signal name="selected-children-changed" handler="on_attachments_selected_changed" swapped="no"/>
-      </object>
-      <packing>
-        <property name="left_attach">0</property>
-        <property name="top_attach">1</property>
-      </packing>
-    </child>
-    <child>
-      <object class="GtkActionBar" id="attachments_actions">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <style>
-          <class name="background"/>
-        </style>
-        <child>
-          <object class="GtkButton" id="open_attachments">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="tooltip_text" translatable="yes">Open selected attachments</property>
-            <property name="action_name">eml.open_attachments</property>
-            <child>
-              <object class="GtkImage">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="icon_name">document-open-symbolic</property>
-              </object>
-            </child>
-          </object>
-          <packing>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkButton" id="save_attachments">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="tooltip_text" translatable="yes">Save selected attachments</property>
-            <property name="action_name">eml.save_attachments</property>
-            <child>
-              <object class="GtkImage">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="icon_name">document-save-symbolic</property>
-              </object>
-            </child>
-          </object>
-          <packing>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkButton" id="select_all_attachments">
-            <property name="visible">False</property>
-            <property name="can_focus">True</property>
-            <property name="tooltip_text" translatable="yes">Select all attachments</property>
-            <property name="action_name">eml.select_all_attachments</property>
-            <child>
-              <object class="GtkImage">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="icon_name">edit-select-all-symbolic</property>
-              </object>
-            </child>
-          </object>
-          <packing>
-          </packing>
-        </child>
-      </object>
-      <packing>
-        <property name="left_attach">0</property>
-        <property name="top_attach">2</property>
-      </packing>
-    </child>
-    <style>
-      <class name="view"/>
-    </style>
-  </object>
-  <object class="GtkListStore" id="attachments_model">
-    <columns>
-      <!-- column-name icon -->
-      <column type="GdkPixbuf"/>
-      <!-- column-name label -->
-      <column type="gchararray"/>
-      <!-- column-name attachment_info -->
-      <column type="GObject"/>
-    </columns>
-  </object>
   <object class="GtkInfoBar" id="draft_infobar">
     <property name="app_paintable">True</property>
     <property name="can_focus">False</property>
diff --git a/ui/geary.css b/ui/geary.css
index 45551567..85b07146 100644
--- a/ui/geary.css
+++ b/ui/geary.css
@@ -198,6 +198,16 @@ grid.geary-message-summary {
   border-radius: 0px;
 }
 
+/* AttachmentPane  */
+
+.geary-attachment-pane flowbox {
+  margin: 6px;
+}
+
+.geary-attachment-pane flowboxchild {
+  border-radius: 3px;
+}
+
 /* PlaceholderPane  */
 
 .geary-placeholder-pane.geary-has-text > image {
diff --git a/ui/org.gnome.Geary.gresource.xml b/ui/org.gnome.Geary.gresource.xml
index 358fe091..be5ea980 100644
--- a/ui/org.gnome.Geary.gresource.xml
+++ b/ui/org.gnome.Geary.gresource.xml
@@ -10,6 +10,9 @@
     <file compressed="true" preprocess="xml-stripblanks">certificate_warning_dialog.glade</file>
     <file compressed="true">client-web-view.js</file>
     <file compressed="true">client-web-view-allow-remote-images.js</file>
+    <file compressed="true" preprocess="xml-stripblanks">components-attachment-pane.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks">components-attachment-pane-menus.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks">components-attachment-view.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">components-in-app-notification.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">components-inspector.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">components-inspector-error-view.ui</file>
@@ -24,7 +27,6 @@
     <file compressed="true">composer-web-view.js</file>
     <file compressed="true" preprocess="xml-stripblanks">conversation-contact-popover.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">conversation-email.ui</file>
-    <file compressed="true" preprocess="xml-stripblanks">conversation-email-attachment-view.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">conversation-email-menus.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">conversation-message.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">conversation-message-menus.ui</file>


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]