Bug 587341 - Implement Undo Close Group inside of Tab Candy [r+a=dietrich]
authorRaymond Lee <raymond@raysquare.com>
Fri, 10 Sep 2010 22:40:27 +0800
changeset 52383 a6b04551c72aff25bbdef2845a69fd51390c513f
parent 52382 a6fd7402dfe65ae5ac12c3c89105260040a69f25
child 52384 65161587c8b4bc9563a7a000384559b853dc2411
push id1
push userroot
push dateTue, 26 Apr 2011 22:38:44 +0000
treeherdermozilla-beta@bfdb6e623a36 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs587341
milestone2.0b6pre
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 587341 - Implement Undo Close Group inside of Tab Candy [r+a=dietrich]
browser/base/content/browser-tabview.js
browser/base/content/tabview/groupitems.js
browser/base/content/tabview/tabitems.js
browser/base/content/tabview/tabview.css
browser/base/content/tabview/ui.js
browser/base/content/test/tabview/Makefile.in
browser/base/content/test/tabview/browser_tabview_bug591706.js
browser/base/content/test/tabview/browser_tabview_dragdrop.js
browser/base/content/test/tabview/browser_tabview_undo_group.js
browser/themes/gnomestripe/browser/tabview/tabview.css
browser/themes/pinstripe/browser/tabview/tabview.css
browser/themes/winstripe/browser/tabview/tabview.css
--- a/browser/base/content/browser-tabview.js
+++ b/browser/base/content/browser-tabview.js
@@ -145,18 +145,21 @@ let TabView = {
     while (popup.firstChild && popup.firstChild != separator)
       popup.removeChild(popup.firstChild);
 
     let self = this;
     this._initFrame(function() {
       let activeGroup = tab.tabItem.parent;
       let groupItems = self._window.GroupItems.groupItems;
 
-      groupItems.forEach(function(groupItem) { 
-        if (groupItem.getTitle().length > 0 && 
+      groupItems.forEach(function(groupItem) {
+        // if group has title, it's not hidden and there is no active group or
+        // the active group id doesn't match the group id, a group menu item
+        // would be added.
+        if (groupItem.getTitle().length > 0 && !groupItem.hidden &&
             (!activeGroup || activeGroup.id != groupItem.id)) {
           let menuItem = self._createGroupMenuItem(groupItem);
           popup.insertBefore(menuItem, separator);
           isEmpty = false;
         }
       });
       separator.hidden = isEmpty;
     });
--- a/browser/base/content/tabview/groupitems.js
+++ b/browser/base/content/tabview/groupitems.js
@@ -72,16 +72,17 @@ function GroupItem(listOfEls, options) {
   this.defaultSize = new Point(TabItems.tabWidth * 1.5, TabItems.tabHeight * 1.5);
   this.isAGroupItem = true;
   this.id = options.id || GroupItems.getNextID();
   this._isStacked = false;
   this._stackAngles = [0];
   this.expanded = null;
   this.locked = (options.locked ? Utils.copy(options.locked) : {});
   this.topChild = null;
+  this.hidden = false;
 
   this.keepProportional = false;
 
   // Variable: _activeTab
   // The <TabItem> for the groupItem's active tab.
   this._activeTab = null;
 
   // Variables: xDensity, yDensity
@@ -264,16 +265,19 @@ function GroupItem(listOfEls, options) {
 
   // ___ locking
   if (this.locked.bounds)
     $container.css({cursor: 'default'});
 
   if (this.locked.close)
     $close.hide();
 
+  // ___ Undo Close
+  this.$undoContainer = null;
+
   // ___ Superclass initialization
   this._init($container[0]);
 
   if (this.$debug)
     this.$debug.css({zIndex: -1000});
 
   // ___ Children
   Array.prototype.forEach.call(listOfEls, function(el) {
@@ -536,39 +540,166 @@ GroupItem.prototype = Utils.extend(new I
   // ----------
   // Function: close
   // Closes the groupItem, removing (but not closing) all of its children.
   close: function GroupItem_close() {
     this.removeAll();
     GroupItems.unregister(this);
     this._sendToSubscribers("close");
     this.removeTrenches();
-    iQ(this.container).fadeOut(function() {
-      iQ(this).remove();
-      Items.unsquish();
+
+    iQ(this.container).animate({
+      opacity: 0,
+      "-moz-transform": "scale(.3)",
+    }, {
+      duration: 170,
+      complete: function() {
+        iQ(this).remove();
+        Items.unsquish();
+      }
     });
 
     Storage.deleteGroupItem(gWindow, this.id);
   },
 
   // ----------
   // Function: closeAll
   // Closes the groupItem and all of its children.
   closeAll: function GroupItem_closeAll() {
-    var self = this;
-    if (this._children.length) {
-      var toClose = this._children.concat();
+    if (this._children.length > 0) {
+      this._children.forEach(function(child) {
+        iQ(child.container).hide();
+      });
+
+      iQ(this.container).animate({
+         opacity: 0,
+         "-moz-transform": "scale(.3)",
+      }, {
+        duration: 170,
+        complete: function() {
+          iQ(this).hide();
+        }
+      });
+
+      this._createUndoButton();
+    } else {
+      if (!this.locked.close)
+        this.close();
+    }
+  },
+
+  // ----------
+  // Function: _createUndoButton
+  // Makes the affordance for undo a close group action
+  _createUndoButton: function() {
+    let self = this;
+    this.$undoContainer = iQ("<div/>")
+      .addClass("undo")
+      .attr("type", "button")
+      .text("Undo Close Group")
+      .appendTo("body");
+    let undoClose = iQ("<span/>")
+      .addClass("close")
+      .appendTo(this.$undoContainer);
+
+    this.$undoContainer.css({
+      left: this.bounds.left + this.bounds.width/2 - iQ(self.$undoContainer).width()/2,
+      top:  this.bounds.top + this.bounds.height/2 - iQ(self.$undoContainer).height()/2,
+      "-moz-transform": "scale(.1)",
+      opacity: 0
+    });
+    this.hidden = true;
+
+    setTimeout(function() {
+      self.$undoContainer.animate({
+        "-moz-transform": "scale(1)",
+        "opacity": 1
+      }, {
+        easing: "tabviewBounce",
+        duration: 170,
+        complete: function() {
+          self._sendToSubscribers("groupHidden", { groupItemId: self.id });
+        }
+      });
+    }, 50);
+
+    let remove = function() {
+      // close all children
+      let toClose = self._children.concat();
       toClose.forEach(function(child) {
         child.removeSubscriber(self, "close");
         child.close();
       });
-    }
+ 
+      // remove all children
+      self.removeAll();
+      GroupItems.unregister(self);
+      self._sendToSubscribers("close");
+      self.removeTrenches();
+
+      iQ(self.container).remove();
+      self.$undoContainer.remove();
+      self.$undoContainer = null;
+      Items.unsquish();
+
+      Storage.deleteGroupItem(gWindow, self.id);
+    };
+
+    this.$undoContainer.click(function(e) {
+      // Only do this for clicks on this actual element.
+      if (e.target.nodeName != self.$undoContainer[0].nodeName)
+        return;
+
+      self.$undoContainer.fadeOut(function() {
+        iQ(this).remove();
+        self.hidden = false;
+        self.$undoContainer = null;
 
-    if (!this.locked.close)
-      this.close();
+        iQ(self.container).show().animate({
+          "-moz-transform": "scale(1)",
+          "opacity": 1
+        }, {
+          duration: 170,
+          complete: function() {
+            self._children.forEach(function(child) {
+              iQ(child.container).show();
+            });
+          }
+        });
+
+        self._sendToSubscribers("groupShown", { groupItemId: self.id });
+      });
+    });
+
+    undoClose.click(function() {
+      self.$undoContainer.fadeOut(remove);
+    });
+
+    // After 15 seconds, fade away.
+    const WAIT = 15000;
+    const FADE = 300;
+
+    let fadeaway = function() {
+      if (self.$undoContainer)
+        self.$undoContainer.animate({
+          color: "transparent",
+          opacity: 0
+        }, {
+          duration: FADE,
+          complete: remove
+        });
+    };
+
+    let timeoutId = setTimeout(fadeaway, WAIT);
+    // Cancel the fadeaway if you move the mouse over the undo
+    // button, and restart the countdown once you move out of it.
+    this.$undoContainer.mouseover(function() clearTimeout(timeoutId));
+    this.$undoContainer.mouseout(function() {
+      timeoutId = setTimeout(fadeaway, WAIT);
+    });
   },
 
   // ----------
   // Function: add
   // Adds an item to the groupItem.
   // Parameters:
   //
   //   a - The item to add. Can be an <Item>, a DOM element or an iQ object.
@@ -1589,24 +1720,23 @@ let GroupItems = {
   // Returns the active groupItem. Active means its tabs are
   // shown in the tab bar when not in the TabView interface.
   getActiveGroupItem: function GroupItems_getActiveGroupItem() {
     return this._activeGroupItem;
   },
 
   // ----------
   // Function: setActiveGroupItem
-  // Sets the active groupItem, thereby showing only the relevent tabs, and
+  // Sets the active groupItem, thereby showing only the relevant tabs and
   // setting the groupItem which will receive new tabs.
   //
   // Paramaters:
   //  groupItem - the active <GroupItem> or <null> if no groupItem is active
   //          (which means we have an orphaned tab selected)
   setActiveGroupItem: function GroupItems_setActiveGroupItem(groupItem) {
-
     if (this._activeGroupItem)
       iQ(this._activeGroupItem.container).removeClass('activeGroupItem');
 
     if (groupItem !== null) {
       if (groupItem)
         iQ(groupItem.container).addClass('activeGroupItem');
       // if a groupItem is active, we surely are not in an orphaned tab.
       this.setActiveOrphanTab(null);
@@ -1691,54 +1821,60 @@ let GroupItems = {
     var tabItem = null;
 
     if (reverse)
       groupItems = groupItems.reverse();
 
     if (!activeGroupItem) {
       if (groupItems.length > 0) {
         groupItems.some(function(groupItem) {
-          var child = groupItem.getChild(0);
-          if (child) {
-            tabItem = child;
-            return true;
+          if (!groupItem.hidden) {
+            var child = groupItem.getChild(0);
+            if (child) {
+              tabItem = child;
+              return true;
+            }
           }
           return false;
         });
       }
     } else {
       var currentIndex;
       groupItems.some(function(groupItem, index) {
-        if (groupItem == activeGroupItem) {
+        if (!groupItem.hidden && groupItem == activeGroupItem) {
           currentIndex = index;
           return true;
         }
         return false;
       });
       var firstGroupItems = groupItems.slice(currentIndex + 1);
       firstGroupItems.some(function(groupItem) {
-        var child = groupItem.getChild(0);
-        if (child) {
-          tabItem = child;
-          return true;
+        if (!groupItem.hidden) {
+          var child = groupItem.getChild(0);
+          if (child) {
+            tabItem = child;
+            return true;
+          }
         }
         return false;
       });
       if (!tabItem) {
         var orphanedTabs = GroupItems.getOrphanedTabs();
         if (orphanedTabs.length > 0)
           tabItem = orphanedTabs[0];
       }
       if (!tabItem) {
         var secondGroupItems = groupItems.slice(0, currentIndex);
         secondGroupItems.some(function(groupItem) {
-          var child = groupItem.getChild(0);
-          if (child) {
-            tabItem = child;
-            return true;
+          if (!groupItem.hidden) {
+            var child = groupItem.getChild(0);
+            if (child) {
+              tabItem = child;
+              return true;
+            }
           }
           return false;
         });
       }
     }
     return tabItem;
   },
 
@@ -1813,10 +1949,30 @@ let GroupItems = {
     // to begin with
     let newTabGroupTitle = "New Tabs";
     this.groupItems.forEach(function(groupItem) {
       if (groupItem.getTitle() == newTabGroupTitle && groupItem.locked.title) {
         groupItem.removeAll();
         groupItem.close();
       }
     });
+  },
+
+  // ----------
+  // Function: removeHiddenGroups
+  // Removes all hidden groups' data and its browser tabs.
+  removeHiddenGroups: function GroupItems_removeHiddenGroups() {
+    iQ(".undo").remove();
+    
+    // ToDo: encapsulate this in the group item. bug 594863
+    this.groupItems.forEach(function(groupItem) {
+      if (groupItem.hidden) {
+        let toClose = groupItem._children.concat();
+        toClose.forEach(function(child) {
+          child.removeSubscriber(groupItem, "close");
+          child.close();
+        });
+
+        Storage.deleteGroupItem(gWindow, groupItem.id);
+      }
+    });
   }
 };
--- a/browser/base/content/tabview/tabitems.js
+++ b/browser/base/content/tabview/tabitems.js
@@ -44,17 +44,16 @@
 
 // ##########
 // Class: TabItem
 // An <Item> that represents a tab. Also implements the <Subscribable> interface.
 //
 // Parameters:
 //   tab - a xul:tab
 function TabItem(tab) {
-
   Utils.assert(tab, "tab");
 
   this.tab = tab;
   // register this as the tab's tabItem
   this.tab.tabItem = this;
 
   // ___ set up div
   var $div = iQ('<div>')
@@ -501,16 +500,20 @@ TabItem.prototype = Utils.extend(new Ite
 
   // ----------
   // Function: zoomIn
   // Allows you to select the tab and zoom in on it, thereby bringing you
   // to the tab in Firefox to interact with.
   // Parameters:
   //   isNewBlankTab - boolean indicates whether it is a newly opened blank tab.
   zoomIn: function TabItem_zoomIn(isNewBlankTab) {
+    // don't allow zoom in if its group is hidden
+    if (this.parent && this.parent.hidden)
+      return;
+
     var self = this;
     var $tabEl = iQ(this.container);
     var childHitResult = { shouldZoom: true };
     if (this.parent)
       childHitResult = this.parent.childHit(this);
 
     if (childHitResult.shouldZoom) {
       // Zoom in!
--- a/browser/base/content/tabview/tabview.css
+++ b/browser/base/content/tabview/tabview.css
@@ -97,16 +97,25 @@ body {
 
 .appTabTray {
   position: absolute;
 }
 
 /* Other Items
 ----------------------------------*/
 
+.undo {
+  position: absolute;
+}
+
+.undo .close {
+  display: inline-block;
+  position: relative;
+}
+
 .info-item {
   position: absolute;
 }
 
 /* Trenches
 ----------------------------------*/
 
 .guideTrench, 
--- a/browser/base/content/tabview/ui.js
+++ b/browser/base/content/tabview/ui.js
@@ -209,18 +209,20 @@ let UI = {
       iQ(window).resize(function() {
         self._resize();
       });
 
       // ___ setup observer to save canvas images
       var observer = {
         observe : function(subject, topic, data) {
           if (topic == "quit-application-requested") {
-            if (self._isTabViewVisible())
+            if (self._isTabViewVisible()) {
+              GroupItems.removeHiddenGroups();
               TabItems.saveAll(true);
+            }
             self._save();
           }
         }
       };
       Services.obs.addObserver(observer, "quit-application-requested", false);
 
       // ___ Done
       this._frameInitalized = true;
@@ -348,16 +350,17 @@ let UI = {
 
   // ----------
   // Function: hideTabView
   // Hides TabView and shows the main browser UI.
   hideTabView: function UI_hideTabView() {
     if (!this._isTabViewVisible())
       return;
 
+    GroupItems.removeHiddenGroups();
     TabItems.pausePainting();
 
     this._reorderTabsOnHide.forEach(function(groupItem) {
       groupItem.reorderTabsBasedOnTabItemOrder();
     });
     this._reorderTabsOnHide = [];
 
 #ifdef XP_WIN
@@ -567,17 +570,18 @@ let UI = {
           event.stopPropagation();
           event.preventDefault();
         }
         return;
       }
 
       function getClosestTabBy(norm) {
         var centers =
-          [[item.bounds.center(), item] for each(item in TabItems.getItems())];
+          [[item.bounds.center(), item] 
+             for each(item in TabItems.getItems()) if (!item.parent || !item.parent.hidden)];
         var myCenter = self.getActiveTab().bounds.center();
         var matches = centers
           .filter(function(item){return norm(item[0], myCenter)})
           .sort(function(a,b){
             return myCenter.distance(a[0]) - myCenter.distance(b[0]);
           });
         if (matches.length > 0)
           return matches[0][1];
--- a/browser/base/content/test/tabview/Makefile.in
+++ b/browser/base/content/test/tabview/Makefile.in
@@ -46,12 +46,13 @@ include $(topsrcdir)/config/rules.mk
 _BROWSER_FILES = \
                  browser_tabview_launch.js \
                  browser_tabview_dragdrop.js \
                  browser_tabview_group.js \
                  browser_tabview_search.js \
                  browser_tabview_snapping.js \
                  browser_tabview_bug591706.js \
                  browser_tabview_apptabs.js \
+                 browser_tabview_undo_group.js \
                  $(NULL)
 
 libs::	$(_BROWSER_FILES)
 	$(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/browser/$(relativesrcdir)
--- a/browser/base/content/test/tabview/browser_tabview_bug591706.js
+++ b/browser/base/content/test/tabview/browser_tabview_bug591706.js
@@ -75,34 +75,43 @@ function onTabViewWindowLoaded() {
   isnot(firstTab.linkedBrowser.contentWindow.location, secondTab.linkedBrowser.contentWindow.location, "The two tabs must have different locations");
 
   // Add the first tab to the group *programmatically*, without specifying a dropPos
   group.add(firstTabItem);
   is(group.getChildren().length, 2, "Two tabs in the group");
   is(group.getChildren()[0].tab.linkedBrowser.contentWindow.location, secondTab.linkedBrowser.contentWindow.location, "The second tab was there first");
   is(group.getChildren()[1].tab.linkedBrowser.contentWindow.location, firstTab.linkedBrowser.contentWindow.location, "The first tab was just added and went to the end of the line");
   
+  group.addSubscriber(group, "close", function() {
+    group.removeSubscriber(group, "close");
+
+    ok(group.isEmpty(), "The group is empty again");
+
+    is(contentWindow.GroupItems.getActiveGroupItem(), null, "The active group is gone");
+    contentWindow.GroupItems.setActiveGroupItem(currentGroup);
+    isnot(contentWindow.GroupItems.getActiveGroupItem(), null, "There is an active group");
+    is(gBrowser.tabs.length, 1, "There is only one tab left");
+    is(gBrowser.visibleTabs.length, 1, "There is also only one visible tab");
+
+    let onTabViewHidden = function() {
+      window.removeEventListener("tabviewhidden", onTabViewHidden, false);
+      finish();
+    };
+    window.addEventListener("tabviewhidden", onTabViewHidden, false);
+    gBrowser.selectedTab = originalTab;
+
+    TabView.hide();
+  });
+
   // Get rid of the group and its children
   group.closeAll();
-  ok(group.isEmpty(), "The group is empty again");
-  
-  is(contentWindow.GroupItems.getActiveGroupItem(), null, "The active group is gone");
-  contentWindow.GroupItems.setActiveGroupItem(currentGroup);
-  isnot(contentWindow.GroupItems.getActiveGroupItem(), null, "There is an active group");
-  is(gBrowser.tabs.length, 1, "There is only one tab left");
-  is(gBrowser.visibleTabs.length, 1, "There is also only one visible tab");
-
-  let onTabViewHidden = function() {
-    window.removeEventListener("tabviewhidden", onTabViewHidden, false);
-    finish();
-  };
-  window.addEventListener("tabviewhidden", onTabViewHidden, false);
-  gBrowser.selectedTab = originalTab;
-
-  TabView.hide();
+  // close undo group
+  let closeButton = group.$undoContainer.find(".close");
+  EventUtils.sendMouseEvent(
+    { type: "click" }, closeButton[0], contentWindow);
 }
 
 function simulateDragDrop(srcElement, offsetX, offsetY, contentWindow) {
   // enter drag mode
   let dataTransfer;
 
   EventUtils.synthesizeMouse(
     srcElement, 1, 1, { type: "mousedown" }, contentWindow);
--- a/browser/base/content/test/tabview/browser_tabview_dragdrop.js
+++ b/browser/base/content/test/tabview/browser_tabview_dragdrop.js
@@ -109,17 +109,21 @@ function addTest(contentWindow, groupOne
 
     is(groupOne.getChildren().length, --groupOneTabItemCount,
        "The number of children in group one is decreased by 1");
     is(groupTwo.getChildren().length, ++groupTwoTabItemCount,
        "The number of children in group two is increased by 1");
   
     let onTabViewHidden = function() {
       window.removeEventListener("tabviewhidden", onTabViewHidden, false);
-       groupTwo.closeAll();
+      groupTwo.closeAll();
+      // close undo group
+      let closeButton = groupTwo.$undoContainer.find(".close");
+      EventUtils.sendMouseEvent(
+        { type: "click" }, closeButton[0], contentWindow);
     };
     groupTwo.addSubscriber(groupTwo, "close", function() {
       groupTwo.removeSubscriber(groupTwo, "close");
       finish();  
     });
     window.addEventListener("tabviewhidden", onTabViewHidden, false);
     gBrowser.selectedTab = originalTab;
   }
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/tabview/browser_tabview_undo_group.js
@@ -0,0 +1,168 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is tabview undo group test.
+ *
+ * The Initial Developer of the Original Code is
+ * Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ * Raymond Lee <raymond@appcoast.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+function test() {
+  waitForExplicitFinish();
+
+  window.addEventListener("tabviewshown", onTabViewWindowLoaded, false);
+  TabView.toggle();
+}
+
+function onTabViewWindowLoaded() {
+  window.removeEventListener("tabviewshown", onTabViewWindowLoaded, false);
+  ok(TabView.isVisible(), "Tab View is visible");
+
+  let contentWindow = document.getElementById("tab-view").contentWindow;
+
+  // create a group item
+  let box = new contentWindow.Rect(20, 400, 300, 300);
+  let groupItem = new contentWindow.GroupItem([], { bounds: box });
+
+  // create a tab item in the new group
+  let onTabViewHidden = function() {
+    window.removeEventListener("tabviewhidden", onTabViewHidden, false);
+
+    ok(!TabView.isVisible(), "Tab View is hidden because we just opened a tab");
+    // show tab view
+    TabView.toggle();
+  };
+  let onTabViewShown = function() {
+    window.removeEventListener("tabviewshown", onTabViewShown, false);
+
+    is(groupItem.getChildren().length, 1, "The new group has a tab item");
+    // start the tests
+    testUndoGroup(contentWindow, groupItem);
+  };
+  window.addEventListener("tabviewhidden", onTabViewHidden, false);
+  window.addEventListener("tabviewshown", onTabViewShown, false);
+
+  // click on the + button
+  let newTabButton = groupItem.container.getElementsByClassName("newTabButton");
+  ok(newTabButton[0], "New tab button exists");
+
+  EventUtils.sendMouseEvent({ type: "click" }, newTabButton[0], contentWindow);
+}
+
+function testUndoGroup(contentWindow, groupItem) {
+  groupItem.addSubscriber(groupItem, "groupHidden", function() {
+    groupItem.removeSubscriber(groupItem, "groupHidden");
+
+    // check the data of the group
+    let theGroupItem = contentWindow.GroupItems.groupItem(groupItem.id);
+    ok(theGroupItem, "The group item still exists");
+    is(theGroupItem.getChildren().length, 1, 
+       "The tab item in the group still exists");
+
+    // check the visibility of the group element and undo element
+    is(theGroupItem.container.style.display, "none", 
+       "The group element is hidden");
+    ok(theGroupItem.$undoContainer, "Undo container is avaliable");
+
+    EventUtils.sendMouseEvent(
+      { type: "click" }, theGroupItem.$undoContainer[0], contentWindow);
+  });
+
+  groupItem.addSubscriber(groupItem, "groupShown", function() {
+    groupItem.removeSubscriber(groupItem, "groupShown");
+
+    // check the data of the group
+    let theGroupItem = contentWindow.GroupItems.groupItem(groupItem.id);
+    ok(theGroupItem, "The group item still exists");
+    is(theGroupItem.getChildren().length, 1, 
+       "The tab item in the group still exists");
+
+    // check the visibility of the group element and undo element
+    is(theGroupItem.container.style.display, "", "The group element is visible");
+    ok(!theGroupItem.$undoContainer, "Undo container is not avaliable");
+    
+    // start the next test
+    testCloseUndoGroup(contentWindow, groupItem);
+  });
+
+  let closeButton = groupItem.container.getElementsByClassName("close");
+  ok(closeButton, "Group item close button exists");
+  EventUtils.sendMouseEvent({ type: "click" }, closeButton[0], contentWindow);
+}
+
+function testCloseUndoGroup(contentWindow, groupItem) {
+  groupItem.addSubscriber(groupItem, "groupHidden", function() {
+    groupItem.removeSubscriber(groupItem, "groupHidden");
+
+    // check the data of the group
+    let theGroupItem = contentWindow.GroupItems.groupItem(groupItem.id);
+    ok(theGroupItem, "The group item still exists");
+    is(theGroupItem.getChildren().length, 1, 
+       "The tab item in the group still exists");
+
+    // check the visibility of the group element and undo element
+    is(theGroupItem.container.style.display, "none", 
+       "The group element is hidden");
+    ok(theGroupItem.$undoContainer, "Undo container is avaliable");
+
+    // click on close
+    let closeButton = theGroupItem.$undoContainer.find(".close");
+    EventUtils.sendMouseEvent(
+      { type: "click" }, closeButton[0], contentWindow);
+  });
+
+  groupItem.addSubscriber(groupItem, "close", function() {
+    groupItem.removeSubscriber(groupItem, "close");
+
+    let theGroupItem = contentWindow.GroupItems.groupItem(groupItem.id);
+    ok(!theGroupItem, "The group item doesn't exists");
+
+    let endGame = function() {
+      window.removeEventListener("tabviewhidden", endGame, false);
+      ok(!TabView.isVisible(), "Tab View is hidden");
+      finish();
+    };
+    window.addEventListener("tabviewhidden", endGame, false);
+
+    // after the last selected tabitem is closed, there would be not active
+    // tabitem on the UI so we set the active tabitem before toggling the 
+    // visibility of tabview
+    let tabItems = contentWindow.TabItems.getItems();
+    ok(tabItems[0], "A tab item exists");
+    contentWindow.UI.setActiveTab(tabItems[0]);
+
+    TabView.toggle();
+  });
+
+  let closeButton = groupItem.container.getElementsByClassName("close");
+  ok(closeButton, "Group item close button exists");
+  EventUtils.sendMouseEvent({ type: "click" }, closeButton[0], contentWindow);
+}
--- a/browser/themes/gnomestripe/browser/tabview/tabview.css
+++ b/browser/themes/gnomestripe/browser/tabview/tabview.css
@@ -189,16 +189,45 @@ body {
 }
 
 .appTabIcon {
   width: 16px;
   height: 16px;
   cursor: pointer;
 }
 
+.undo {
+  background-color: #A0A0A0;
+  width: 150px;
+  height: 30px;
+  line-height: 30px;
+  -moz-box-shadow: 0px 1px 0px rgba(255,255,255,.5), 0px -1px 0px rgba(0,0,0,.24);
+  text-shadow: 0px -1px 0px rgba(255,255,255,.2);
+  color: rgba( 0,0,0, .8);
+  border-radius: 0.4em;
+  text-align: center;
+  border: none;
+  cursor: pointer;
+}
+
+.undo:hover {
+  background-color: #949494;
+}
+
+.undo .close {
+  top: 4px;
+  left: 4px;
+  opacity: 0.5;
+}
+
+.undo .close:hover{
+  opacity: 1.0;
+}
+
+
 /* InfoItems
 ----------------------------------*/
 
 .info-item {
   cursor: move;
   border: 1px solid rgba(230,230,230,1);
   background-color: rgba(248,248,248,1);
   border-radius: 0.4em;
--- a/browser/themes/pinstripe/browser/tabview/tabview.css
+++ b/browser/themes/pinstripe/browser/tabview/tabview.css
@@ -196,16 +196,44 @@ body {
 }
 
 .appTabIcon {
   width: 16px;
   height: 16px;
   cursor: pointer;
 }
 
+.undo {
+  background-color: #A0A0A0;
+  width: 150px;
+  height: 30px;
+  line-height: 30px;
+  -moz-box-shadow: 0px 1px 0px rgba(255,255,255,.5), 0px -1px 0px rgba(0,0,0,.24);
+  text-shadow: 0px -1px 0px rgba(255,255,255,.2);
+  color: rgba( 0,0,0, .8);
+  border-radius: 0.4em;
+  text-align: center;
+  border: none;
+  cursor: pointer;
+}
+
+.undo:hover {
+  background-color: #949494;
+}
+
+.undo .close {
+  top: 4px;
+  left: 4px;
+  opacity: 0.5;
+}
+
+.undo .close:hover{
+  opacity: 1.0;
+}
+
 /* InfoItems
 ----------------------------------*/
 
 .info-item {
   cursor: move;
   border: 1px solid rgba(230,230,230,1);
   background-color: rgba(248,248,248,1);
   border-radius: 0.4em;
--- a/browser/themes/winstripe/browser/tabview/tabview.css
+++ b/browser/themes/winstripe/browser/tabview/tabview.css
@@ -201,16 +201,44 @@ body {
 }
 
 .appTabIcon {
   width: 16px;
   height: 16px;
   cursor: pointer;
 }
 
+.undo {
+  background-color: #A0A0A0;
+  width: 150px;
+  height: 30px;
+  line-height: 30px;
+  -moz-box-shadow: 0px 1px 0px rgba(255,255,255,.5), 0px -1px 0px rgba(0,0,0,.24);
+  text-shadow: 0px -1px 0px rgba(255,255,255,.2);
+  color: rgba( 0,0,0, .8);
+  border-radius: 0.4em;
+  text-align: center;
+  border: none;
+  cursor: pointer;
+}
+
+.undo:hover {
+  background-color: #949494;
+}
+
+.undo .close {
+  top: 4px;
+  left: 4px;
+  opacity: 0.5;
+}
+
+.undo .close:hover{
+  opacity: 1.0;
+}
+
 /* InfoItems
 ----------------------------------*/
 
 .info-item {
   cursor: move;
   border: 1px solid rgba(230,230,230,1);
   background-color: rgba(248,248,248,1);
   border-radius: 0.4em;