[geary/wip/721828-undo] Round-trip of archive/trash and undo works



commit 81ed97044a5cd8d191130fcb645fa3b2b9b0bb79
Author: Jim Nelson <jim yorba org>
Date:   Tue Dec 23 14:58:00 2014 -0800

    Round-trip of archive/trash and undo works
    
    Slow right now, but basics and plumbing are in place.

 src/CMakeLists.txt                                 |    2 +
 src/client/application/geary-controller.vala       |   63 ++++++++++++++-
 src/client/components/main-toolbar.vala            |    8 ++-
 src/engine/api/geary-folder-supports-archive.vala  |   10 ++-
 src/engine/api/geary-folder-supports-copy.vala     |    4 +-
 src/engine/api/geary-folder-supports-move.vala     |    4 +-
 src/engine/api/geary-revokable.vala                |   49 ++++++++++++
 .../gmail/imap-engine-gmail-folder.vala            |   15 +++-
 .../imap-engine/imap-engine-minimal-folder.vala    |   55 +++++++++++++-
 .../imap-engine/imap-engine-revokable-move.vala    |   80 ++++++++++++++++++++
 10 files changed, 273 insertions(+), 17 deletions(-)
---
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 251b3b0..3ce824a 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -38,6 +38,7 @@ engine/api/geary-logging.vala
 engine/api/geary-named-flag.vala
 engine/api/geary-named-flags.vala
 engine/api/geary-progress-monitor.vala
+engine/api/geary-revokable.vala
 engine/api/geary-search-folder.vala
 engine/api/geary-search-query.vala
 engine/api/geary-service.vala
@@ -191,6 +192,7 @@ engine/imap-engine/imap-engine-generic-folder.vala
 engine/imap-engine/imap-engine-minimal-folder.vala
 engine/imap-engine/imap-engine-replay-operation.vala
 engine/imap-engine/imap-engine-replay-queue.vala
+engine/imap-engine/imap-engine-revokable-move.vala
 engine/imap-engine/imap-engine-send-replay-operation.vala
 engine/imap-engine/gmail/imap-engine-gmail-account.vala
 engine/imap-engine/gmail/imap-engine-gmail-folder.vala
diff --git a/src/client/application/geary-controller.vala b/src/client/application/geary-controller.vala
index 0116df7..87e6124 100644
--- a/src/client/application/geary-controller.vala
+++ b/src/client/application/geary-controller.vala
@@ -48,6 +48,7 @@ public class GearyController : Geary.BaseObject {
     public const string ACTION_MOVE_MENU = "GearyMoveMenuButton";
     public const string ACTION_GEAR_MENU = "GearyGearMenuButton";
     public const string ACTION_SEARCH = "GearySearch";
+    public const string ACTION_UNDO = "GearyUndo";
     
     public const string PROP_CURRENT_CONVERSATION ="current-conversations";
     
@@ -124,6 +125,7 @@ public class GearyController : Geary.BaseObject {
     private Gee.List<string> pending_mailtos = new Gee.ArrayList<string>();
     private Geary.Nonblocking.Mutex untrusted_host_prompt_mutex = new Geary.Nonblocking.Mutex();
     private Gee.HashSet<Geary.Endpoint> validating_endpoints = new Gee.HashSet<Geary.Endpoint>();
+    private Geary.Revokable? revokable = null;
     
     // List of windows we're waiting to close before Geary closes.
     private Gee.List<ComposerWidget> waiting_to_close = new Gee.ArrayList<ComposerWidget>();
@@ -237,6 +239,9 @@ public class GearyController : Geary.BaseObject {
         // instantiate here to ensure that Config is initialized and ready
         autostart_manager = new AutostartManager();
         
+        // initialize revokable
+        save_revokable(null, null);
+        
         // Start Geary.
         try {
             yield Geary.Engine.instance.open_async(GearyApplication.instance.get_user_data_directory(), 
@@ -410,6 +415,9 @@ public class GearyController : Geary.BaseObject {
         entries += search;
         add_accelerator("<Ctrl>S", ACTION_SEARCH);
         
+        Gtk.ActionEntry undo = { ACTION_UNDO, null, null, "<Ctrl>Z", null, on_revoke };
+        entries += undo;
+        
         return entries;
     }
     
@@ -2297,10 +2305,13 @@ public class GearyController : Geary.BaseObject {
             debug("Archiving selected messages");
             
             Geary.FolderSupport.Archive? supports_archive = current_folder as Geary.FolderSupport.Archive;
-            if (supports_archive == null)
+            if (supports_archive == null) {
                 debug("Folder %s doesn't support archive", current_folder.to_string());
-            else
-                yield supports_archive.archive_email_async(ids, cancellable);
+            } else {
+                save_revokable(yield supports_archive.archive_email_async(ids, cancellable),
+                    _("Undo archive"));
+            }
+            
             return;
         }
         
@@ -2312,7 +2323,9 @@ public class GearyController : Geary.BaseObject {
                     Geary.SpecialFolderType.TRASH, cancellable)).path;
                 Geary.FolderSupport.Move? supports_move = current_folder as Geary.FolderSupport.Move;
                 if (supports_move != null) {
-                    yield supports_move.move_email_async(ids, trash_path, cancellable);
+                    save_revokable(yield supports_move.move_email_async(ids, trash_path, cancellable),
+                        _("Undo trash"));
+                    
                     return;
                 }
             }
@@ -2343,6 +2356,48 @@ public class GearyController : Geary.BaseObject {
         }
     }
     
+    private void save_revokable(Geary.Revokable? new_revokable, string? description) {
+        // disconnect old revokable
+        if (revokable != null)
+            revokable.notify[Geary.Revokable.PROP_CAN_REVOKE].disconnect(on_can_revoke_changed);
+        
+        // store new revokable
+        revokable = new_revokable;
+        
+        // connect to new revokable
+        if (revokable != null)
+            revokable.notify[Geary.Revokable.PROP_CAN_REVOKE].connect(on_can_revoke_changed);
+        
+        Gtk.Action undo_action = GearyApplication.instance.get_action(ACTION_UNDO);
+        undo_action.sensitive = revokable != null && revokable.can_revoke;
+        undo_action.tooltip = (revokable != null && description != null) ? description : "";
+    }
+    
+    private void on_can_revoke_changed() {
+        // remove revokable if it goes invalid
+        if (revokable != null && !revokable.can_revoke)
+            save_revokable(null, null);
+    }
+    
+    private void on_revoke() {
+        if (revokable != null && revokable.can_revoke)
+            revokable.revoke_async.begin(null, on_revoke_completed);
+    }
+    
+    private void on_revoke_completed(Object? object, AsyncResult result) {
+        // Don't use the "revokable" instance because it might have gone null before this callback
+        // was reached
+        Geary.Revokable? origin = object as Geary.Revokable;
+        if (origin == null)
+            return;
+        
+        try {
+            origin.revoke_async.end(result);
+        } catch (Error err) {
+            debug("Unable to revoke operation: %s", err.message);
+        }
+    }
+    
     private void on_zoom_in() {
         main_window.conversation_viewer.web_view.zoom_in();
     }
diff --git a/src/client/components/main-toolbar.vala b/src/client/components/main-toolbar.vala
index 4f0a960..ecf7999 100644
--- a/src/client/components/main-toolbar.vala
+++ b/src/client/components/main-toolbar.vala
@@ -71,7 +71,11 @@ public class MainToolbar : PillHeaderbar {
         Gtk.Box trash_archive = create_pill_buttons(insert);
         insert.clear();
         insert.add(trash_buttons[1] = create_toolbar_button(null, GearyController.ACTION_TRASH_MESSAGE, 
true));
-        Gtk.Box trash = create_pill_buttons(insert, false);
+        Gtk.Box trash = create_pill_buttons(insert, false, false);
+        
+        insert.clear();
+        insert.add(create_toolbar_button("edit-undo-symbolic", GearyController.ACTION_UNDO));
+        Gtk.Box undo = create_pill_buttons(insert, false, false);
         
         // Search bar.
         search_entry.width_chars = 28;
@@ -90,6 +94,7 @@ public class MainToolbar : PillHeaderbar {
 #if !GTK_3_12
         add_end(trash_archive);
         add_end(trash);
+        add_end(undo);
         add_end(search_upgrade_progress_bar);
         add_end(search_entry);
 #endif
@@ -105,6 +110,7 @@ public class MainToolbar : PillHeaderbar {
 #if GTK_3_12
         add_end(search_entry);
         add_end(search_upgrade_progress_bar);
+        add_end(undo);
         add_end(trash);
         add_end(trash_archive);
 #endif
diff --git a/src/engine/api/geary-folder-supports-archive.vala 
b/src/engine/api/geary-folder-supports-archive.vala
index 9796a76..73ce06d 100644
--- a/src/engine/api/geary-folder-supports-archive.vala
+++ b/src/engine/api/geary-folder-supports-archive.vala
@@ -18,21 +18,25 @@ public interface Geary.FolderSupport.Archive : Geary.Folder {
      * Archives the specified emails from the folder.
      *
      * The { link Geary.Folder} must be opened prior to attempting this operation.
+     *
+     * @returns A { link Geary.Revokable} that may be used to revoke (undo) this operation later.
      */
-    public abstract async void archive_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
+    public abstract async Geary.Revokable? archive_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
         Cancellable? cancellable = null) throws Error;
     
     /**
      * Archive one email from the folder.
      *
      * The { link Geary.Folder} must be opened prior to attempting this operation.
+     *
+     * @returns A { link Geary.Revokable} that may be used to revoke (undo) this operation later.
      */
-    public virtual async void archive_single_email_async(Geary.EmailIdentifier email_id,
+    public virtual async Geary.Revokable? archive_single_email_async(Geary.EmailIdentifier email_id,
         Cancellable? cancellable = null) throws Error {
         Gee.ArrayList<Geary.EmailIdentifier> ids = new Gee.ArrayList<Geary.EmailIdentifier>();
         ids.add(email_id);
         
-        yield archive_email_async(ids, cancellable);
+        return yield archive_email_async(ids, cancellable);
     }
 }
 
diff --git a/src/engine/api/geary-folder-supports-copy.vala b/src/engine/api/geary-folder-supports-copy.vala
index 351a16a..ca2f3b7 100644
--- a/src/engine/api/geary-folder-supports-copy.vala
+++ b/src/engine/api/geary-folder-supports-copy.vala
@@ -20,8 +20,10 @@ public interface Geary.FolderSupport.Copy : Geary.Folder {
      * but will return success.
      *
      * The Folder must be opened prior to attempting this operation.
+     *
+     * @returns A { link Geary.Revokable} that may be used to revoke (undo) this operation later.
      */
-    public abstract async void copy_email_async(Gee.List<Geary.EmailIdentifier> to_copy,
+    public abstract async Geary.Revokable? copy_email_async(Gee.List<Geary.EmailIdentifier> to_copy,
         Geary.FolderPath destination, Cancellable? cancellable = null) throws Error;
 }
 
diff --git a/src/engine/api/geary-folder-supports-move.vala b/src/engine/api/geary-folder-supports-move.vala
index 58ededf..e76062f 100644
--- a/src/engine/api/geary-folder-supports-move.vala
+++ b/src/engine/api/geary-folder-supports-move.vala
@@ -19,8 +19,10 @@ public interface Geary.FolderSupport.Move : Geary.Folder {
      * way but will return success.
      *
      * The { link Geary.Folder} must be opened prior to attempting this operation.
+     *
+     * @returns A { link Geary.Revokable} that may be used to revoke (undo) this operation later.
      */
-    public abstract async void move_email_async(Gee.List<Geary.EmailIdentifier> to_move,
+    public abstract async Geary.Revokable? move_email_async(Gee.List<Geary.EmailIdentifier> to_move,
         Geary.FolderPath destination, Cancellable? cancellable = null) throws Error;
 }
 
diff --git a/src/engine/api/geary-revokable.vala b/src/engine/api/geary-revokable.vala
new file mode 100644
index 0000000..51b6f95
--- /dev/null
+++ b/src/engine/api/geary-revokable.vala
@@ -0,0 +1,49 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+/**
+ * A representation of an operation with the Geary Engine that make be revoked (undone) at a later
+ * time.
+ */
+
+public abstract class Geary.Revokable : BaseObject {
+    public const string PROP_CAN_REVOKE = "can-revoke";
+    public const string PROP_IS_REVOKING = "is-revoking";
+    
+    /**
+     * Indicates if { link revoke_async} is a valid operation for this { link Revokable}.
+     *
+     * Due to later operations or notifications, it's possible for the Revokable to go invalid.
+     * In some circumstances, this may be that it cannot fully revoke the original operation, in
+     * others it may be that it can't revoke any part of the original operation, depending on the
+     * nature of the operation.
+     */
+    public bool can_revoke { get; protected set; default = true; }
+    
+    /**
+     * Indicates a { link revoke_async} operation is underway.
+     *
+     * Only one revoke operation can occur at a time.  If this is true when revoke_async() is
+     * called, it will throw an Error.
+     */
+    public bool is_revoking { get; protected set; default = false; }
+    
+    protected Revokable() {
+    }
+    
+    /**
+     * Revoke (undo) the operation.
+     *
+     * Returns false if the operation failed and is no longer revokable.
+     *
+     * If the call throws an Error that does not necessarily mean the { link Revokable} is
+     * invalid.  Check the return value or { link can_revoke}.
+     *
+     * @throws EngineError.ALREADY_OPEN if { link is_revoking} is true when called.
+     */
+    public abstract async bool revoke_async(Cancellable? cancellable = null) throws Error;
+}
+
diff --git a/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala 
b/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala
index 0d233ec..8cb3a25 100644
--- a/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala
+++ b/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala
@@ -17,12 +17,21 @@ private class Geary.ImapEngine.GmailFolder : MinimalFolder, FolderSupport.Archiv
         return yield base.create_email_async(rfc822, flags, date_received, id, cancellable);
     }
     
-    public async void archive_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
+    public async Geary.Revokable? archive_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
         Cancellable? cancellable = null) throws Error {
-        // TODO: Use move_email_async("All Mail") here; Gmail will do the right thing and report
+        // Use move_email_async("All Mail") here; Gmail will do the right thing and report
         // it was copied with the All Mail UID (in other words, no actual copy is performed).
-        // Stash returned All Mail identifier as the Undo identifier
+        // This allows for undoing an archive with the same code path as a move.
+        Geary.Folder? all_mail = account.get_special_folder(Geary.SpecialFolderType.ALL_MAIL);
+        if (all_mail != null)
+            return yield move_email_async(email_ids, all_mail.path, cancellable);
+        
+        // although this shouldn't happen, fall back on our traditional archive, which is simply
+        // to remove the message from this label
+        message("Unable to perform revokable archive on %s: All Mail not found", to_string());
         yield expunge_email_async(email_ids, cancellable);
+        
+        return null;
     }
 }
 
diff --git a/src/engine/imap-engine/imap-engine-minimal-folder.vala 
b/src/engine/imap-engine/imap-engine-minimal-folder.vala
index 45e49f0..2fa79b1 100644
--- a/src/engine/imap-engine/imap-engine-minimal-folder.vala
+++ b/src/engine/imap-engine/imap-engine-minimal-folder.vala
@@ -46,6 +46,9 @@ private class Geary.ImapEngine.MinimalFolder : Geary.AbstractFolder, Geary.Folde
     private uint open_remote_timer_id = 0;
     private int reestablish_delay_msec = DEFAULT_REESTABLISH_DELAY_MSEC;
     
+    // Used by ImapEngine Revokables.
+    public signal void uids_removed(Gee.Collection<Imap.UID> uids);
+    
     public MinimalFolder(GenericAccount account, Imap.Account remote, ImapDB.Account local,
         ImapDB.Folder local_folder, SpecialFolderType special_folder_type) {
         _account = account;
@@ -72,6 +75,24 @@ private class Geary.ImapEngine.MinimalFolder : Geary.AbstractFolder, Geary.Folde
         local_folder.email_complete.disconnect(on_email_complete);
     }
     
+    public override void notify_email_removed(Gee.Collection<Geary.EmailIdentifier> ids) {
+        // fire base signal first
+        base.notify_email_removed(ids);
+        
+        // parse out UIDs and fire signal for Revokables
+        Gee.HashSet<Imap.UID> uids = new Gee.HashSet<Imap.UID>();
+        foreach (Geary.EmailIdentifier id in ids) {
+            ImapDB.EmailIdentifier? imapdb_id = id as ImapDB.EmailIdentifier;
+            if (imapdb_id != null && imapdb_id.uid != null)
+                uids.add(imapdb_id.uid);
+        }
+        
+        debug("Notifying of %d UIDs removed from %s", uids.size, to_string());
+        
+        if (uids.size > 0)
+            uids_removed(uids);
+    }
+    
     public void set_special_folder_type(SpecialFolderType new_type) {
         SpecialFolderType old_type = _special_folder_type;
         _special_folder_type = new_type;
@@ -1237,32 +1258,36 @@ private class Geary.ImapEngine.MinimalFolder : Geary.AbstractFolder, Geary.Folde
         yield mark.wait_for_ready_async(cancellable);
     }
 
-    public virtual async void copy_email_async(Gee.List<Geary.EmailIdentifier> to_copy,
+    public virtual async Geary.Revokable? copy_email_async(Gee.List<Geary.EmailIdentifier> to_copy,
         Geary.FolderPath destination, Cancellable? cancellable = null) throws Error {
         check_open("copy_email_async");
         check_ids("copy_email_async", to_copy);
         
         // watch for copying to this folder, which is treated as a no-op
         if (destination.equal_to(path))
-            return;
+            return null;
         
         CopyEmail copy = new CopyEmail(this, (Gee.List<ImapDB.EmailIdentifier>) to_copy, destination);
         replay_queue.schedule(copy);
         yield copy.wait_for_ready_async(cancellable);
+        
+        return null;
     }
 
-    public virtual async void move_email_async(Gee.List<Geary.EmailIdentifier> to_move,
+    public virtual async Geary.Revokable? move_email_async(Gee.List<Geary.EmailIdentifier> to_move,
         Geary.FolderPath destination, Cancellable? cancellable = null) throws Error {
         check_open("move_email_async");
         check_ids("move_email_async", to_move);
         
         // watch for moving to this folder, which is treated as a no-op
         if (destination.equal_to(path))
-            return;
+            return null;
         
         MoveEmail move = new MoveEmail(this, (Gee.List<ImapDB.EmailIdentifier>) to_move, destination);
         replay_queue.schedule(move);
         yield move.wait_for_ready_async(cancellable);
+        
+        return new RevokableMove(account, path, destination, move.destination_uids);
     }
     
     private void on_email_flags_changed(Gee.Map<Geary.EmailIdentifier, Geary.EmailFlags> changed) {
@@ -1359,5 +1384,27 @@ private class Geary.ImapEngine.MinimalFolder : Geary.AbstractFolder, Geary.Folde
         
         return ret;
     }
+    
+    // To be used only by RevokableMove.  Return false if UIDs are unknown to local store.
+    internal async bool revoke_move_async(Gee.Collection<Imap.UID> uids, FolderPath source,
+        Cancellable? cancellable) throws Error {
+        check_open("revoke_move_async");
+        
+        // need to wait for fully open to ensure UIDs are normalized between local and remove
+        yield wait_for_open_async(cancellable);
+        
+        Gee.Set<ImapDB.EmailIdentifier>? ids = yield local_folder.get_ids_async(uids,
+            ImapDB.Folder.ListFlags.NONE, cancellable);
+        if (ids == null || ids.size == 0)
+            return false;
+        
+        MoveEmail move = new MoveEmail(this, traverse<ImapDB.EmailIdentifier>(ids).to_array_list(),
+            source, cancellable);
+        replay_queue.schedule(move);
+        
+        yield move.wait_for_ready_async(cancellable);
+        
+        return true;
+    }
 }
 
diff --git a/src/engine/imap-engine/imap-engine-revokable-move.vala 
b/src/engine/imap-engine/imap-engine-revokable-move.vala
new file mode 100644
index 0000000..80c047e
--- /dev/null
+++ b/src/engine/imap-engine/imap-engine-revokable-move.vala
@@ -0,0 +1,80 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+private class Geary.ImapEngine.RevokableMove : Revokable {
+    private Account account;
+    private FolderPath original_source;
+    private FolderPath original_dest;
+    private Gee.Set<Imap.UID> uids;
+    private MinimalFolder? dest_folder = null;
+    
+    public RevokableMove(Account account, FolderPath original_source, FolderPath original_dest,
+        Gee.Set<Imap.UID> uids) {
+        this.account = account;
+        this.original_source = original_source;
+        this.original_dest = original_dest;
+        this.uids = uids;
+    }
+    
+    public override async bool revoke_async(Cancellable? cancellable) throws Error {
+        if (is_revoking)
+            throw new EngineError.ALREADY_OPEN("Already revoking operation");
+        
+        is_revoking = true;
+        try {
+            return yield internal_revoke_async(cancellable);
+        } finally {
+            is_revoking = false;
+        }
+    }
+    
+    private async bool internal_revoke_async(Cancellable? cancellable) throws Error {
+        // moving from original destination to original source
+        try {
+            Geary.Folder folder = yield account.fetch_folder_async(original_dest, cancellable);
+            dest_folder = folder as ImapEngine.MinimalFolder;
+        } catch (Error err) {
+            debug("Unable to revoke move to %s: %s", original_dest.to_string(), err.message);
+        }
+        
+        if (dest_folder == null)
+            return can_revoke = false;
+        
+        // trap removal of the UIDs we're moving
+        dest_folder.uids_removed.connect(on_folder_uids_removed);
+        
+        // open, revoke, close, ensuring the close and signal disconnect are performed in all cases
+        try {
+            yield dest_folder.open_async(Geary.Folder.OpenFlags.NO_DELAY, cancellable);
+            
+            if (!yield dest_folder.revoke_move_async(uids, original_source, cancellable))
+                can_revoke = false;
+        } finally {
+            // note that the Cancellable is not used
+            try {
+                yield dest_folder.close_async();
+            } catch (Error err) {
+                // ignored
+            }
+            
+            dest_folder.uids_removed.disconnect(on_folder_uids_removed);
+            dest_folder = null;
+        }
+        
+        return can_revoke;
+    }
+    
+    private void on_folder_uids_removed(Gee.Collection<Imap.UID> removed_uids) {
+        // one-way switch
+        if (!can_revoke)
+            return;
+        
+        // otherwise, ability to revoke is best-effort
+        uids.remove_all(removed_uids);
+        can_revoke = uids.size > 0;
+    }
+}
+


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