diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index 38eea38f949987165fd98aa070ba168f4d8301a6..97e80afa3f8dc48f9f9986d46dce23ea70cde7da 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -7,7 +7,7 @@ class BoardService {
     this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
       issues: {
         method: 'GET',
-        url: `${gon.relative_url_root}/boards/${boardId}/issues.json`,
+        url: `${gon.relative_url_root}/-/boards/${boardId}/issues.json`,
       }
     });
     this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, {
@@ -16,7 +16,7 @@ class BoardService {
         url: `${listsEndpoint}/generate.json`
       }
     });
-    this.issue = Vue.resource(`${gon.relative_url_root}/boards/${boardId}/issues{/id}`, {});
+    this.issue = Vue.resource(`${gon.relative_url_root}/-/boards/${boardId}/issues{/id}`, {});
     this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
       bulkUpdate: {
         method: 'POST',
diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js
index 50dbeb063624ff6dc3eb11dac0abb6ed08dfe80c..180aa30e98c99189856e842f9404b3f865a86799 100644
--- a/app/assets/javascripts/clusters.js
+++ b/app/assets/javascripts/clusters.js
@@ -3,7 +3,8 @@ import Visibility from 'visibilityjs';
 import axios from 'axios';
 import Poll from './lib/utils/poll';
 import { s__ } from './locale';
-import './flash';
+import initSettingsPanels from './settings_panels';
+import Flash from './flash';
 
 /**
  * Cluster page has 2 separate parts:
@@ -24,6 +25,8 @@ class ClusterService {
 
 export default class Clusters {
   constructor() {
+    initSettingsPanels();
+
     const dataset = document.querySelector('.js-edit-cluster-form').dataset;
 
     this.state = {
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index cd53bc1e2dd973c5e14c775ced6484b1d8814b2b..b66652db33b3f7d9a5d811f08300afd52368031d 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -73,6 +73,7 @@ import initProjectVisibilitySelector from './project_visibility';
 import GpgBadges from './gpg_badges';
 import UserFeatureHelper from './helpers/user_feature_helper';
 import initChangesDropdown from './init_changes_dropdown';
+import NewGroupChild from './groups/new_group_child';
 import AbuseReports from './abuse_reports';
 import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
 import AjaxLoadingSpinner from './ajax_loading_spinner';
@@ -168,9 +169,6 @@ import memberExpirationDate from './member_expiration_date';
             const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
             filteredSearchManager.setup();
           }
-          if (page === 'projects:merge_requests:index') {
-            new UserCallout({ setCalloutPerProject: true });
-          }
           const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
           IssuableIndex.init(pagePrefix);
 
@@ -352,7 +350,10 @@ import memberExpirationDate from './member_expiration_date';
         case 'projects:show':
           shortcut_handler = new ShortcutsNavigation();
           new NotificationsForm();
-          new UserCallout({ setCalloutPerProject: true });
+          new UserCallout({
+            setCalloutPerProject: true,
+            className: 'js-autodevops-banner',
+          });
 
           if ($('#tree-slider').length) new TreeView();
           if ($('.blob-viewer').length) new BlobViewer();
@@ -372,9 +373,6 @@ import memberExpirationDate from './member_expiration_date';
         case 'projects:pipelines:new':
           new NewBranchForm($('.js-new-pipeline-form'));
           break;
-        case 'projects:pipelines:index':
-          new UserCallout({ setCalloutPerProject: true });
-          break;
         case 'projects:pipelines:builds':
         case 'projects:pipelines:failures':
         case 'projects:pipelines:show':
@@ -395,10 +393,15 @@ import memberExpirationDate from './member_expiration_date';
           new gl.Activities();
           break;
         case 'groups:show':
+          const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
           shortcut_handler = new ShortcutsNavigation();
           new NotificationsForm();
           new NotificationsDropdown();
           new ProjectsList();
+
+          if (newGroupChildWrapper) {
+            new NewGroupChild(newGroupChildWrapper);
+          }
           break;
         case 'groups:group_members:index':
           memberExpirationDate();
@@ -432,7 +435,6 @@ import memberExpirationDate from './member_expiration_date';
           new TreeView();
           new BlobViewer();
           new NewCommitForm($('.js-create-dir-form'));
-          new UserCallout({ setCalloutPerProject: true });
           $('#tree-slider').waitForImages(function() {
             ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
           });
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 6d516a253bb6e7865c600ad5a610ab286729dd92..9e91f72b2ea5ad4b5338eced9803ce2f11d7a634 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -6,10 +6,11 @@ import _ from 'underscore';
  */
 
 export default class FilterableList {
-  constructor(form, filter, holder) {
+  constructor(form, filter, holder, filterInputField = 'filter_groups') {
     this.filterForm = form;
     this.listFilterElement = filter;
     this.listHolderElement = holder;
+    this.filterInputField = filterInputField;
     this.isBusy = false;
   }
 
@@ -32,10 +33,10 @@ export default class FilterableList {
   onFilterInput() {
     const $form = $(this.filterForm);
     const queryData = {};
-    const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+    const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
 
     if (filterGroupsParam) {
-      queryData.filter_groups = filterGroupsParam;
+      queryData[this.filterInputField] = filterGroupsParam;
     }
 
     this.filterResults(queryData);
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2c0b6ab4ea8c48d83b6fca12d017a1c80ffa3c87
--- /dev/null
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -0,0 +1,194 @@
+<script>
+/* global Flash */
+
+import eventHub from '../event_hub';
+import { getParameterByName } from '../../lib/utils/common_utils';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import { COMMON_STR } from '../constants';
+
+import groupsComponent from './groups.vue';
+
+export default {
+  components: {
+    loadingIcon,
+    groupsComponent,
+  },
+  props: {
+    store: {
+      type: Object,
+      required: true,
+    },
+    service: {
+      type: Object,
+      required: true,
+    },
+    hideProjects: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      isLoading: true,
+      isSearchEmpty: false,
+      searchEmptyMessage: '',
+    };
+  },
+  computed: {
+    groups() {
+      return this.store.getGroups();
+    },
+    pageInfo() {
+      return this.store.getPaginationInfo();
+    },
+  },
+  methods: {
+    fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
+      return this.service.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
+                .then((res) => {
+                  if (updatePagination) {
+                    this.updatePagination(res.headers);
+                  }
+
+                  return res;
+                })
+                .then(res => res.json())
+                .catch(() => {
+                  this.isLoading = false;
+                  $.scrollTo(0);
+
+                  Flash(COMMON_STR.FAILURE);
+                });
+    },
+    fetchAllGroups() {
+      const page = getParameterByName('page') || null;
+      const sortBy = getParameterByName('sort') || null;
+      const archived = getParameterByName('archived') || null;
+      const filterGroupsBy = getParameterByName('filter') || null;
+
+      this.isLoading = true;
+      // eslint-disable-next-line promise/catch-or-return
+      this.fetchGroups({
+        page,
+        filterGroupsBy,
+        sortBy,
+        archived,
+        updatePagination: true,
+      }).then((res) => {
+        this.isLoading = false;
+        this.updateGroups(res, Boolean(filterGroupsBy));
+      });
+    },
+    fetchPage(page, filterGroupsBy, sortBy, archived) {
+      this.isLoading = true;
+
+      // eslint-disable-next-line promise/catch-or-return
+      this.fetchGroups({
+        page,
+        filterGroupsBy,
+        sortBy,
+        archived,
+        updatePagination: true,
+      }).then((res) => {
+        this.isLoading = false;
+        $.scrollTo(0);
+
+        const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
+        window.history.replaceState({
+          page: currentPath,
+        }, document.title, currentPath);
+
+        this.updateGroups(res);
+      });
+    },
+    toggleChildren(group) {
+      const parentGroup = group;
+      if (!parentGroup.isOpen) {
+        if (parentGroup.children.length === 0) {
+          parentGroup.isChildrenLoading = true;
+          // eslint-disable-next-line promise/catch-or-return
+          this.fetchGroups({
+            parentId: parentGroup.id,
+          }).then((res) => {
+            this.store.setGroupChildren(parentGroup, res);
+          }).catch(() => {
+            parentGroup.isChildrenLoading = false;
+          });
+        } else {
+          parentGroup.isOpen = true;
+        }
+      } else {
+        parentGroup.isOpen = false;
+      }
+    },
+    leaveGroup(group, parentGroup) {
+      const targetGroup = group;
+      targetGroup.isBeingRemoved = true;
+      this.service.leaveGroup(targetGroup.leavePath)
+        .then(res => res.json())
+        .then((res) => {
+          $.scrollTo(0);
+          this.store.removeGroup(targetGroup, parentGroup);
+          Flash(res.notice, 'notice');
+        })
+        .catch((err) => {
+          let message = COMMON_STR.FAILURE;
+          if (err.status === 403) {
+            message = COMMON_STR.LEAVE_FORBIDDEN;
+          }
+          Flash(message);
+          targetGroup.isBeingRemoved = false;
+        });
+    },
+    updatePagination(headers) {
+      this.store.setPaginationInfo(headers);
+    },
+    updateGroups(groups, fromSearch) {
+      this.isSearchEmpty = groups ? groups.length === 0 : false;
+      if (fromSearch) {
+        this.store.setSearchedGroups(groups);
+      } else {
+        this.store.setGroups(groups);
+      }
+    },
+  },
+  created() {
+    this.searchEmptyMessage = this.hideProjects ?
+      COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
+
+    eventHub.$on('fetchPage', this.fetchPage);
+    eventHub.$on('toggleChildren', this.toggleChildren);
+    eventHub.$on('leaveGroup', this.leaveGroup);
+    eventHub.$on('updatePagination', this.updatePagination);
+    eventHub.$on('updateGroups', this.updateGroups);
+  },
+  mounted() {
+    this.fetchAllGroups();
+  },
+  beforeDestroy() {
+    eventHub.$off('fetchPage', this.fetchPage);
+    eventHub.$off('toggleChildren', this.toggleChildren);
+    eventHub.$off('leaveGroup', this.leaveGroup);
+    eventHub.$off('updatePagination', this.updatePagination);
+    eventHub.$off('updateGroups', this.updateGroups);
+  },
+};
+</script>
+
+<template>
+  <div>
+    <loading-icon
+      class="loading-animation prepend-top-20"
+      size="2"
+      v-if="isLoading"
+      :label="s__('GroupsTree|Loading groups')"
+    />
+    <groups-component
+      v-if="!isLoading"
+      :groups="groups"
+      :search-empty="isSearchEmpty"
+      :search-empty-message="searchEmptyMessage"
+      :page-info="pageInfo"
+    />
+  </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 7cc6c4b0359b8216da517b69750d0de20967ec11..e60221fa08dfa910e1bb5c1f7cf293bdbad53ccb 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -1,15 +1,27 @@
 <script>
+import { n__ } from '../../locale';
+import { MAX_CHILDREN_COUNT } from '../constants';
+
 export default {
   props: {
-    groups: {
-      type: Object,
-      required: true,
-    },
-    baseGroup: {
+    parentGroup: {
       type: Object,
       required: false,
       default: () => ({}),
     },
+    groups: {
+      type: Array,
+      required: false,
+      default: () => ([]),
+    },
+  },
+  computed: {
+    hasMoreChildren() {
+      return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT;
+    },
+    moreChildrenStats() {
+      return n__('One more item', '%d more items', this.parentGroup.childrenCount - this.parentGroup.children.length);
+    },
   },
 };
 </script>
@@ -20,8 +32,20 @@ export default {
       v-for="(group, index) in groups"
       :key="index"
       :group="group"
-      :base-group="baseGroup"
-      :collection="groups"
+      :parent-group="parentGroup"
     />
+    <li
+      v-if="hasMoreChildren"
+      class="group-row">
+      <a
+        :href="parentGroup.relativePath"
+        class="group-row-contents has-more-items">
+        <i
+          class="fa fa-external-link"
+          aria-hidden="true"
+        />
+        {{moreChildrenStats}}
+      </a>
+    </li>
   </ul>
 </template>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 2060410e991c34ae26d4ac6c32348e381406ea29..356a95c05ca820109deb9ca2b8376eff92f82a16 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -2,49 +2,28 @@
 import identicon from '../../vue_shared/components/identicon.vue';
 import eventHub from '../event_hub';
 
+import itemCaret from './item_caret.vue';
+import itemTypeIcon from './item_type_icon.vue';
+import itemStats from './item_stats.vue';
+import itemActions from './item_actions.vue';
+
 export default {
   components: {
     identicon,
+    itemCaret,
+    itemTypeIcon,
+    itemStats,
+    itemActions,
   },
   props: {
-    group: {
-      type: Object,
-      required: true,
-    },
-    baseGroup: {
+    parentGroup: {
       type: Object,
       required: false,
       default: () => ({}),
     },
-    collection: {
+    group: {
       type: Object,
-      required: false,
-      default: () => ({}),
-    },
-  },
-  methods: {
-    onClickRowGroup(e) {
-      e.stopPropagation();
-
-      // Skip for buttons
-      if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) {
-        if (this.group.hasSubgroups) {
-          eventHub.$emit('toggleSubGroups', this.group);
-        } else {
-          window.location.href = this.group.groupPath;
-        }
-      }
-    },
-    onLeaveGroup(e) {
-      e.preventDefault();
-
-      // eslint-disable-next-line no-alert
-      if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) {
-        this.leaveGroup();
-      }
-    },
-    leaveGroup() {
-      eventHub.$emit('leaveGroup', this.group, this.collection);
+      required: true,
     },
   },
   computed: {
@@ -53,51 +32,33 @@ export default {
     },
     rowClass() {
       return {
-        'group-row': true,
         'is-open': this.group.isOpen,
-        'has-subgroups': this.group.hasSubgroups,
-        'no-description': !this.group.description,
+        'has-children': this.hasChildren,
+        'has-description': this.group.description,
+        'being-removed': this.group.isBeingRemoved,
       };
     },
-    visibilityIcon() {
-      return {
-        fa: true,
-        'fa-globe': this.group.visibility === 'public',
-        'fa-shield': this.group.visibility === 'internal',
-        'fa-lock': this.group.visibility === 'private',
-      };
+    hasChildren() {
+      return this.group.childrenCount > 0;
     },
-    fullPath() {
-      let fullPath = '';
-
-      if (this.group.isOrphan) {
-        // check if current group is baseGroup
-        if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) {
-          // Remove baseGroup prefix from our current group.fullName. e.g:
-          // baseGroup.fullName: `level1`
-          // group.fullName: `level1 / level2 / level3`
-          // Result: `level2 / level3`
-          const gfn = this.group.fullName;
-          const bfn = this.baseGroup.fullName;
-          const length = bfn.length;
-          const start = gfn.indexOf(bfn);
-          const extraPrefixChars = 3;
-
-          fullPath = gfn.substr(start + length + extraPrefixChars);
+    hasAvatar() {
+      return this.group.avatarUrl !== null;
+    },
+    isGroup() {
+      return this.group.type === 'group';
+    },
+  },
+  methods: {
+    onClickRowGroup(e) {
+      const NO_EXPAND_CLS = 'no-expand';
+      if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
+            e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
+        if (this.hasChildren) {
+          eventHub.$emit('toggleChildren', this.group);
         } else {
-          fullPath = this.group.fullName;
+          gl.utils.visitUrl(this.group.relativePath);
         }
-      } else {
-        fullPath = this.group.name;
       }
-
-      return fullPath;
-    },
-    hasGroups() {
-      return Object.keys(this.group.subGroups).length > 0;
-    },
-    hasAvatar() {
-      return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1;
     },
   },
 };
@@ -108,98 +69,36 @@ export default {
     @click.stop="onClickRowGroup"
     :id="groupDomId"
     :class="rowClass"
+    class="group-row"
     >
     <div
       class="group-row-contents">
-      <div
-        class="controls">
-        <a
-          v-if="group.canEdit"
-          class="edit-group btn"
-          :href="group.editPath">
-          <i
-            class="fa fa-cogs"
-            aria-hidden="true"
-          >
-          </i>
-        </a>
-        <a
-          @click="onLeaveGroup"
-          :href="group.leavePath"
-          class="leave-group btn"
-          title="Leave this group">
-          <i
-            class="fa fa-sign-out"
-            aria-hidden="true"
-          >
-          </i>
-        </a>
-      </div>
-      <div
-        class="stats">
-        <span
-          class="number-projects">
-          <i
-            class="fa fa-bookmark"
-            aria-hidden="true"
-          >
-          </i>
-          {{group.numberProjects}}
-        </span>
-        <span
-          class="number-users">
-          <i
-            class="fa fa-users"
-            aria-hidden="true"
-          >
-          </i>
-          {{group.numberUsers}}
-        </span>
-        <span
-          class="group-visibility">
-          <i
-            :class="visibilityIcon"
-            aria-hidden="true"
-          >
-          </i>
-        </span>
-      </div>
+      <item-actions
+        v-if="isGroup"
+        :group="group"
+        :parent-group="parentGroup"
+      />
+      <item-stats
+        :item="group"
+      />
       <div
         class="folder-toggle-wrap">
-        <span
-          class="folder-caret"
-          v-if="group.hasSubgroups">
-          <i
-            v-if="group.isOpen"
-            class="fa fa-caret-down"
-            aria-hidden="true"
-          >
-          </i>
-          <i
-            v-if="!group.isOpen"
-            class="fa fa-caret-right"
-            aria-hidden="true"
-          >
-          </i>
-        </span>
-        <span class="folder-icon">
-          <i
-            v-if="group.isOpen"
-            class="fa fa-folder-open"
-            aria-hidden="true"
-          >
-          </i>
-          <i
-            v-if="!group.isOpen"
-            class="fa fa-folder"
-            aria-hidden="true">
-          </i>
-        </span>
+        <item-caret
+          :is-group-open="group.isOpen"
+        />
+        <item-type-icon
+          :item-type="group.type"
+          :is-group-open="group.isOpen"
+        />
       </div>
       <div
-        class="avatar-container s40 hidden-xs">
+        class="avatar-container s40 hidden-xs"
+        :class="{ 'content-loading': group.isChildrenLoading }"
+      >
         <a
-          :href="group.groupPath">
+          :href="group.relativePath"
+          class="no-expand"
+        >
           <img
             v-if="hasAvatar"
             class="avatar s40"
@@ -215,19 +114,22 @@ export default {
       <div
         class="title">
         <a
-          :href="group.groupPath">{{fullPath}}</a>
-        <template v-if="group.permissions.humanGroupAccess">
-        as
-        <span class="access-type">{{group.permissions.humanGroupAccess}}</span>
-        </template>
+          :href="group.relativePath"
+          class="no-expand">{{group.fullName}}</a>
+        <span
+          v-if="group.permission"
+          class="access-type"
+        >
+          {{s__('GroupsTreeRole|as')}} {{group.permission}}
+        </span>
       </div>
       <div
         class="description">{{group.description}}</div>
     </div>
     <group-folder
-      v-if="group.isOpen && hasGroups"
-      :groups="group.subGroups"
-      :baseGroup="group"
+      v-if="group.isOpen && hasChildren"
+      :parent-group="group"
+      :groups="group.children"
     />
   </li>
 </template>
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index d17a43b048a987e2c6d7b31b382149916f25d16c..75a2bf34887863948b75d7965cc71d27c30ab117 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -4,24 +4,33 @@ import eventHub from '../event_hub';
 import { getParameterByName } from '../../lib/utils/common_utils';
 
 export default {
+  components: {
+    tablePagination,
+  },
   props: {
     groups: {
-      type: Object,
+      type: Array,
       required: true,
     },
     pageInfo: {
       type: Object,
       required: true,
     },
-  },
-  components: {
-    tablePagination,
+    searchEmpty: {
+      type: Boolean,
+      required: true,
+    },
+    searchEmptyMessage: {
+      type: String,
+      required: true,
+    },
   },
   methods: {
     change(page) {
       const filterGroupsParam = getParameterByName('filter_groups');
       const sortParam = getParameterByName('sort');
-      eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam);
+      const archivedParam = getParameterByName('archived');
+      eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
     },
   },
 };
@@ -29,10 +38,17 @@ export default {
 
 <template>
   <div class="groups-list-tree-container">
+    <div
+      v-if="searchEmpty"
+      class="has-no-search-results">
+      {{searchEmptyMessage}}
+    </div>
     <group-folder
+      v-if="!searchEmpty"
       :groups="groups"
     />
     <table-pagination
+      v-if="!searchEmpty"
       :change="change"
       :pageInfo="pageInfo"
     />
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7eff19e2e5af0ff5987ebc576424c16595b6434f
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -0,0 +1,93 @@
+<script>
+import { s__ } from '../../locale';
+import tooltip from '../../vue_shared/directives/tooltip';
+import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
+import eventHub from '../event_hub';
+import { COMMON_STR } from '../constants';
+
+export default {
+  components: {
+    PopupDialog,
+  },
+  directives: {
+    tooltip,
+  },
+  props: {
+    parentGroup: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+    group: {
+      type: Object,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      dialogStatus: false,
+    };
+  },
+  computed: {
+    leaveBtnTitle() {
+      return COMMON_STR.LEAVE_BTN_TITLE;
+    },
+    editBtnTitle() {
+      return COMMON_STR.EDIT_BTN_TITLE;
+    },
+    leaveConfirmationMessage() {
+      return s__(`GroupsTree|Are you sure you want to leave the "${this.group.fullName}" group?`);
+    },
+  },
+  methods: {
+    onLeaveGroup() {
+      this.dialogStatus = true;
+    },
+    leaveGroup(leaveConfirmed) {
+      this.dialogStatus = false;
+      if (leaveConfirmed) {
+        eventHub.$emit('leaveGroup', this.group, this.parentGroup);
+      }
+    },
+  },
+};
+</script>
+
+<template>
+  <div class="controls">
+    <a
+      v-tooltip
+      v-if="group.canEdit"
+      :href="group.editPath"
+      :title="editBtnTitle"
+      :aria-label="editBtnTitle"
+      data-container="body"
+      class="edit-group btn no-expand">
+      <i
+        class="fa fa-cogs"
+        aria-hidden="true"/>
+    </a>
+    <a
+      v-tooltip
+      v-if="group.canLeave"
+      @click.prevent="onLeaveGroup"
+      :href="group.leavePath"
+      :title="leaveBtnTitle"
+      :aria-label="leaveBtnTitle"
+      data-container="body"
+      class="leave-group btn no-expand">
+      <i
+        class="fa fa-sign-out"
+        aria-hidden="true"/>
+    </a>
+    <popup-dialog
+      v-show="dialogStatus"
+      :primary-button-label="__('Leave')"
+      kind="warning"
+      :title="__('Are you sure?')"
+      :text="__('Are you sure you want to leave this group?')"
+      :body="leaveConfirmationMessage"
+      @submit="leaveGroup"
+    />
+  </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
new file mode 100644
index 0000000000000000000000000000000000000000..959b984816f7661b98a7fece014257059be543d1
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+  props: {
+    isGroupOpen: {
+      type: Boolean,
+      required: true,
+      default: false,
+    },
+  },
+  computed: {
+    iconClass() {
+      return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right';
+    },
+  },
+};
+</script>
+
+<template>
+  <span class="folder-caret">
+    <i
+      :class="iconClass"
+      class="fa"
+      aria-hidden="true"/>
+  </span>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9f8ac138fc370733de7a18f6d207284dc675a2f8
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -0,0 +1,98 @@
+<script>
+import tooltip from '../../vue_shared/directives/tooltip';
+import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE } from '../constants';
+
+export default {
+  directives: {
+    tooltip,
+  },
+  props: {
+    item: {
+      type: Object,
+      required: true,
+    },
+  },
+  computed: {
+    visibilityIcon() {
+      return VISIBILITY_TYPE_ICON[this.item.visibility];
+    },
+    visibilityTooltip() {
+      if (this.item.type === ITEM_TYPE.GROUP) {
+        return GROUP_VISIBILITY_TYPE[this.item.visibility];
+      }
+      return PROJECT_VISIBILITY_TYPE[this.item.visibility];
+    },
+    isProject() {
+      return this.item.type === ITEM_TYPE.PROJECT;
+    },
+    isGroup() {
+      return this.item.type === ITEM_TYPE.GROUP;
+    },
+  },
+};
+</script>
+
+<template>
+  <div class="stats">
+    <span
+      v-tooltip
+      v-if="isGroup"
+      :title="s__('Subgroups')"
+      class="number-subgroups"
+      data-placement="top"
+      data-container="body">
+      <i
+        class="fa fa-folder"
+        aria-hidden="true"
+      />
+      {{item.subgroupCount}}
+    </span>
+    <span
+      v-tooltip
+      v-if="isGroup"
+      :title="s__('Projects')"
+      class="number-projects"
+      data-placement="top"
+      data-container="body">
+      <i
+        class="fa fa-bookmark"
+        aria-hidden="true"
+      />
+      {{item.projectCount}}
+    </span>
+    <span
+      v-tooltip
+      v-if="isGroup"
+      :title="s__('Members')"
+      class="number-users"
+      data-placement="top"
+      data-container="body">
+      <i
+        class="fa fa-users"
+        aria-hidden="true"
+      />
+      {{item.memberCount}}
+    </span>
+    <span
+      v-if="isProject"
+      class="project-stars">
+      <i
+        class="fa fa-star"
+        aria-hidden="true"
+      />
+      {{item.starCount}}
+    </span>
+    <span
+      v-tooltip
+      :title="visibilityTooltip"
+      data-placement="left"
+      data-container="body"
+      class="item-visibility">
+      <i
+        :class="visibilityIcon"
+        class="fa"
+        aria-hidden="true"
+      />
+    </span>
+  </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c02a8ad6d8c784735c38e07d83e230f63a34a67f
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_type_icon.vue
@@ -0,0 +1,34 @@
+<script>
+import { ITEM_TYPE } from '../constants';
+
+export default {
+  props: {
+    itemType: {
+      type: String,
+      required: true,
+    },
+    isGroupOpen: {
+      type: Boolean,
+      required: true,
+      default: false,
+    },
+  },
+  computed: {
+    iconClass() {
+      if (this.itemType === ITEM_TYPE.GROUP) {
+        return this.isGroupOpen ? 'fa-folder-open' : 'fa-folder';
+      }
+      return 'fa-bookmark';
+    },
+  },
+};
+</script>
+
+<template>
+  <span class="item-type-icon">
+    <i
+      :class="iconClass"
+      class="fa"
+      aria-hidden="true"/>
+  </span>
+</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..6fde41414b3d30844670608344aa0a6b673fa7de
--- /dev/null
+++ b/app/assets/javascripts/groups/constants.js
@@ -0,0 +1,35 @@
+import { __, s__ } from '../locale';
+
+export const MAX_CHILDREN_COUNT = 20;
+
+export const COMMON_STR = {
+  FAILURE: __('An error occurred. Please try again.'),
+  LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'),
+  LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
+  EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
+  GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'),
+  GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'),
+};
+
+export const ITEM_TYPE = {
+  PROJECT: 'project',
+  GROUP: 'group',
+};
+
+export const GROUP_VISIBILITY_TYPE = {
+  public: __('Public - The group and any public projects can be viewed without any authentication.'),
+  internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'),
+  private: __('Private - The group and its projects can only be viewed by members.'),
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+  public: __('Public - The project can be accessed without any authentication.'),
+  internal: __('Internal - The project can be accessed by any logged in user.'),
+  private: __('Private - Project access must be granted explicitly to each user.'),
+};
+
+export const VISIBILITY_TYPE_ICON = {
+  public: 'fa-globe',
+  internal: 'fa-shield',
+  private: 'fa-lock',
+};
diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js
index 83b102764ba05e4c8575b560cf3bafb723caaebb..2db233b09dace7f234035eddc178e10af99fedac 100644
--- a/app/assets/javascripts/groups/groups_filterable_list.js
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -3,12 +3,13 @@ import eventHub from './event_hub';
 import { getParameterByName } from '../lib/utils/common_utils';
 
 export default class GroupFilterableList extends FilterableList {
-  constructor({ form, filter, holder, filterEndpoint, pagePath }) {
-    super(form, filter, holder);
+  constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
+    super(form, filter, holder, filterInputField);
     this.form = form;
     this.filterEndpoint = filterEndpoint;
     this.pagePath = pagePath;
-    this.$dropdown = $('.js-group-filter-dropdown-wrap');
+    this.filterInputField = filterInputField;
+    this.$dropdown = $(dropdownSel);
   }
 
   getFilterEndpoint() {
@@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList {
   bindEvents() {
     super.bindEvents();
 
-    this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
     this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
 
-    this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
     this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
   }
 
-  onFormSubmit(e) {
-    e.preventDefault();
-
-    const $form = $(this.form);
-    const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+  onFilterInput() {
     const queryData = {};
+    const $form = $(this.form);
+    const archivedParam = getParameterByName('archived', window.location.href);
+    const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
 
     if (filterGroupsParam) {
-      queryData.filter_groups = filterGroupsParam;
+      queryData[this.filterInputField] = filterGroupsParam;
+    }
+
+    if (archivedParam) {
+      queryData.archived = archivedParam;
     }
 
     this.filterResults(queryData);
-    this.setDefaultFilterOption();
+
+    if (this.setDefaultFilterOption) {
+      this.setDefaultFilterOption();
+    }
   }
 
   setDefaultFilterOption() {
-    const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
+    const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text());
     this.$dropdown.find('.dropdown-label').text(defaultOption);
   }
 
@@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList {
     e.preventDefault();
 
     const queryData = {};
-    const sortParam = getParameterByName('sort', e.currentTarget.href);
+
+    // Get type of option selected from dropdown
+    const currentTargetClassList = e.currentTarget.parentElement.classList;
+    const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order');
+    const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects');
+
+    // Get option query param, also preserve currently applied query param
+    const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href);
+    const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href);
 
     if (sortParam) {
       queryData.sort = sortParam;
     }
 
+    if (archivedParam) {
+      queryData.archived = archivedParam;
+    }
+
     this.filterResults(queryData);
 
     // Active selected option
-    this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+    if (isOptionFilterBySort) {
+      this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+      this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active');
+    } else if (isOptionFilterByArchivedProjects) {
+      this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects a').removeClass('is-active');
+    }
+
+    $(e.target).addClass('is-active');
 
     // Clear current value on search form
-    this.form.querySelector('[name="filter_groups"]').value = '';
+    this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
   }
 
   onFilterSuccess(data, xhr, queryData) {
-    super.onFilterSuccess(data, xhr, queryData);
+    const currentPath = this.getPagePath(queryData);
 
     const paginationData = {
       'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
@@ -82,7 +106,11 @@ export default class GroupFilterableList extends FilterableList {
       'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
     };
 
-    eventHub.$emit('updateGroups', data);
+    window.history.replaceState({
+      page: currentPath,
+    }, document.title, currentPath);
+
+    eventHub.$emit('updateGroups', data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
     eventHub.$emit('updatePagination', paginationData);
   }
 }
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index 600bae24b52b11ae84dc96896b6545230287e611..8b850765a1b9dc700a69acb7c1949127ae533a53 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -1,16 +1,17 @@
 import Vue from 'vue';
-import Flash from '../flash';
+import Translate from '../vue_shared/translate';
 import GroupFilterableList from './groups_filterable_list';
-import GroupsComponent from './components/groups.vue';
-import GroupFolder from './components/group_folder.vue';
-import GroupItem from './components/group_item.vue';
-import GroupsStore from './stores/groups_store';
-import GroupsService from './services/groups_service';
-import eventHub from './event_hub';
-import { getParameterByName } from '../lib/utils/common_utils';
+import GroupsStore from './store/groups_store';
+import GroupsService from './service/groups_service';
+
+import groupsApp from './components/app.vue';
+import groupFolderComponent from './components/group_folder.vue';
+import groupItemComponent from './components/group_item.vue';
+
+Vue.use(Translate);
 
 document.addEventListener('DOMContentLoaded', () => {
-  const el = document.getElementById('dashboard-group-app');
+  const el = document.getElementById('js-groups-tree');
 
   // Don't do anything if element doesn't exist (No groups)
   // This is for when the user enters directly to the page via URL
@@ -18,176 +19,56 @@ document.addEventListener('DOMContentLoaded', () => {
     return;
   }
 
-  Vue.component('groups-component', GroupsComponent);
-  Vue.component('group-folder', GroupFolder);
-  Vue.component('group-item', GroupItem);
+  Vue.component('group-folder', groupFolderComponent);
+  Vue.component('group-item', groupItemComponent);
 
   // eslint-disable-next-line no-new
   new Vue({
     el,
+    components: {
+      groupsApp,
+    },
     data() {
-      this.store = new GroupsStore();
-      this.service = new GroupsService(el.dataset.endpoint);
+      const dataset = this.$options.el.dataset;
+      const hideProjects = dataset.hideProjects === 'true';
+      const store = new GroupsStore(hideProjects);
+      const service = new GroupsService(dataset.endpoint);
 
       return {
-        store: this.store,
-        isLoading: true,
-        state: this.store.state,
+        store,
+        service,
+        hideProjects,
         loading: true,
       };
     },
-    computed: {
-      isEmpty() {
-        return Object.keys(this.state.groups).length === 0;
-      },
-    },
-    methods: {
-      fetchGroups(parentGroup) {
-        let parentId = null;
-        let getGroups = null;
-        let page = null;
-        let sort = null;
-        let pageParam = null;
-        let sortParam = null;
-        let filterGroups = null;
-        let filterGroupsParam = null;
-
-        if (parentGroup) {
-          parentId = parentGroup.id;
-        } else {
-          this.isLoading = true;
-        }
-
-        pageParam = getParameterByName('page');
-        if (pageParam) {
-          page = pageParam;
-        }
-
-        filterGroupsParam = getParameterByName('filter_groups');
-        if (filterGroupsParam) {
-          filterGroups = filterGroupsParam;
-        }
-
-        sortParam = getParameterByName('sort');
-        if (sortParam) {
-          sort = sortParam;
-        }
-
-        getGroups = this.service.getGroups(parentId, page, filterGroups, sort);
-        getGroups
-          .then(response => response.json())
-          .then((response) => {
-            this.isLoading = false;
-
-            this.updateGroups(response, parentGroup);
-          })
-          .catch(this.handleErrorResponse);
-
-        return getGroups;
-      },
-      fetchPage(page, filterGroups, sort) {
-        this.isLoading = true;
-
-        return this.service
-          .getGroups(null, page, filterGroups, sort)
-          .then((response) => {
-            this.isLoading = false;
-            $.scrollTo(0);
-
-            const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
-            window.history.replaceState({
-              page: currentPath,
-            }, document.title, currentPath);
-
-            return response.json().then((data) => {
-              this.updateGroups(data);
-              this.updatePagination(response.headers);
-            });
-          })
-          .catch(this.handleErrorResponse);
-      },
-      toggleSubGroups(parentGroup = null) {
-        if (!parentGroup.isOpen) {
-          this.store.resetGroups(parentGroup);
-          this.fetchGroups(parentGroup);
-        }
-
-        this.store.toggleSubGroups(parentGroup);
-      },
-      leaveGroup(group, collection) {
-        this.service.leaveGroup(group.leavePath)
-          .then(resp => resp.json())
-          .then((response) => {
-            $.scrollTo(0);
-
-            this.store.removeGroup(group, collection);
-
-            // eslint-disable-next-line no-new
-            new Flash(response.notice, 'notice');
-          })
-          .catch((error) => {
-            let message = 'An error occurred. Please try again.';
-
-            if (error.status === 403) {
-              message = 'Failed to leave the group. Please make sure you are not the only owner';
-            }
-
-            // eslint-disable-next-line no-new
-            new Flash(message);
-          });
-      },
-      updateGroups(groups, parentGroup) {
-        this.store.setGroups(groups, parentGroup);
-      },
-      updatePagination(headers) {
-        this.store.storePagination(headers);
-      },
-      handleErrorResponse() {
-        this.isLoading = false;
-        $.scrollTo(0);
-
-        // eslint-disable-next-line no-new
-        new Flash('An error occurred. Please try again.');
-      },
-    },
-    created() {
-      eventHub.$on('fetchPage', this.fetchPage);
-      eventHub.$on('toggleSubGroups', this.toggleSubGroups);
-      eventHub.$on('leaveGroup', this.leaveGroup);
-      eventHub.$on('updateGroups', this.updateGroups);
-      eventHub.$on('updatePagination', this.updatePagination);
-    },
     beforeMount() {
+      const dataset = this.$options.el.dataset;
       let groupFilterList = null;
-      const form = document.querySelector('form#group-filter-form');
-      const filter = document.querySelector('.js-groups-list-filter');
-      const holder = document.querySelector('.js-groups-list-holder');
+      const form = document.querySelector(dataset.formSel);
+      const filter = document.querySelector(dataset.filterSel);
+      const holder = document.querySelector(dataset.holderSel);
 
       const opts = {
         form,
         filter,
         holder,
-        filterEndpoint: el.dataset.endpoint,
-        pagePath: el.dataset.path,
+        filterEndpoint: dataset.endpoint,
+        pagePath: dataset.path,
+        dropdownSel: dataset.dropdownSel,
+        filterInputField: 'filter',
       };
 
       groupFilterList = new GroupFilterableList(opts);
       groupFilterList.initSearch();
     },
-    mounted() {
-      this.fetchGroups()
-        .then((response) => {
-          this.updatePagination(response.headers);
-          this.isLoading = false;
-        })
-        .catch(this.handleErrorResponse);
-    },
-    beforeDestroy() {
-      eventHub.$off('fetchPage', this.fetchPage);
-      eventHub.$off('toggleSubGroups', this.toggleSubGroups);
-      eventHub.$off('leaveGroup', this.leaveGroup);
-      eventHub.$off('updateGroups', this.updateGroups);
-      eventHub.$off('updatePagination', this.updatePagination);
+    render(createElement) {
+      return createElement('groups-app', {
+        props: {
+          store: this.store,
+          service: this.service,
+          hideProjects: this.hideProjects,
+        },
+      });
     },
   });
 });
diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js
new file mode 100644
index 0000000000000000000000000000000000000000..8e273579aae359e7b73934ccd0fe48733f47a779
--- /dev/null
+++ b/app/assets/javascripts/groups/new_group_child.js
@@ -0,0 +1,62 @@
+import DropLab from '../droplab/drop_lab';
+import ISetter from '../droplab/plugins/input_setter';
+
+const InputSetter = Object.assign({}, ISetter);
+
+const NEW_PROJECT = 'new-project';
+const NEW_SUBGROUP = 'new-subgroup';
+
+export default class NewGroupChild {
+  constructor(buttonWrapper) {
+    this.buttonWrapper = buttonWrapper;
+    this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child');
+    this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle');
+    this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu');
+
+    this.newGroupPath = this.buttonWrapper.dataset.projectPath;
+    this.subgroupPath = this.buttonWrapper.dataset.subgroupPath;
+
+    this.init();
+  }
+
+  init() {
+    this.initDroplab();
+    this.bindEvents();
+  }
+
+  initDroplab() {
+    this.droplab = new DropLab();
+    this.droplab.init(
+      this.dropdownToggle,
+      this.dropdownList,
+      [InputSetter],
+      this.getDroplabConfig(),
+    );
+  }
+
+  getDroplabConfig() {
+    return {
+      InputSetter: [{
+        input: this.newGroupChildButton,
+        valueAttribute: 'data-value',
+        inputAttribute: 'data-action',
+      }, {
+        input: this.newGroupChildButton,
+        valueAttribute: 'data-text',
+      }],
+    };
+  }
+
+  bindEvents() {
+    this.newGroupChildButton
+      .addEventListener('click', this.onClickNewGroupChildButton.bind(this));
+  }
+
+  onClickNewGroupChildButton(e) {
+    if (e.target.dataset.action === NEW_PROJECT) {
+      gl.utils.visitUrl(this.newGroupPath);
+    } else if (e.target.dataset.action === NEW_SUBGROUP) {
+      gl.utils.visitUrl(this.subgroupPath);
+    }
+  }
+}
diff --git a/app/assets/javascripts/groups/services/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js
similarity index 80%
rename from app/assets/javascripts/groups/services/groups_service.js
rename to app/assets/javascripts/groups/service/groups_service.js
index 97e02fcb76d9662fb360fddec971e47689065650..639410384c28e0dff0342196e00a4cc55f431df5 100644
--- a/app/assets/javascripts/groups/services/groups_service.js
+++ b/app/assets/javascripts/groups/service/groups_service.js
@@ -8,7 +8,7 @@ export default class GroupsService {
     this.groups = Vue.resource(endpoint);
   }
 
-  getGroups(parentId, page, filterGroups, sort) {
+  getGroups(parentId, page, filterGroups, sort, archived) {
     const data = {};
 
     if (parentId) {
@@ -20,12 +20,16 @@ export default class GroupsService {
       }
 
       if (filterGroups) {
-        data.filter_groups = filterGroups;
+        data.filter = filterGroups;
       }
 
       if (sort) {
         data.sort = sort;
       }
+
+      if (archived) {
+        data.archived = archived;
+      }
     }
 
     return this.groups.get(data);
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
new file mode 100644
index 0000000000000000000000000000000000000000..a1689f4c5cc7c3de50fc2445fd9ad3d02c168d69
--- /dev/null
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -0,0 +1,105 @@
+import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
+
+export default class GroupsStore {
+  constructor(hideProjects) {
+    this.state = {};
+    this.state.groups = [];
+    this.state.pageInfo = {};
+    this.hideProjects = hideProjects;
+  }
+
+  setGroups(rawGroups) {
+    if (rawGroups && rawGroups.length) {
+      this.state.groups = rawGroups.map(rawGroup => this.formatGroupItem(rawGroup));
+    } else {
+      this.state.groups = [];
+    }
+  }
+
+  setSearchedGroups(rawGroups) {
+    const formatGroups = groups => groups.map((group) => {
+      const formattedGroup = this.formatGroupItem(group);
+      if (formattedGroup.children && formattedGroup.children.length) {
+        formattedGroup.children = formatGroups(formattedGroup.children);
+      }
+      return formattedGroup;
+    });
+
+    if (rawGroups && rawGroups.length) {
+      this.state.groups = formatGroups(rawGroups);
+    } else {
+      this.state.groups = [];
+    }
+  }
+
+  setGroupChildren(parentGroup, children) {
+    const updatedParentGroup = parentGroup;
+    updatedParentGroup.children = children.map(rawChild => this.formatGroupItem(rawChild));
+    updatedParentGroup.isOpen = true;
+    updatedParentGroup.isChildrenLoading = false;
+  }
+
+  getGroups() {
+    return this.state.groups;
+  }
+
+  setPaginationInfo(pagination = {}) {
+    let paginationInfo;
+
+    if (Object.keys(pagination).length) {
+      const normalizedHeaders = normalizeHeaders(pagination);
+      paginationInfo = parseIntPagination(normalizedHeaders);
+    } else {
+      paginationInfo = pagination;
+    }
+
+    this.state.pageInfo = paginationInfo;
+  }
+
+  getPaginationInfo() {
+    return this.state.pageInfo;
+  }
+
+  formatGroupItem(rawGroupItem) {
+    const groupChildren = rawGroupItem.children || [];
+    const groupIsOpen = (groupChildren.length > 0) || false;
+    const childrenCount = this.hideProjects ?
+      rawGroupItem.subgroup_count :
+      rawGroupItem.children_count;
+
+    return {
+      id: rawGroupItem.id,
+      name: rawGroupItem.name,
+      fullName: rawGroupItem.full_name,
+      description: rawGroupItem.description,
+      visibility: rawGroupItem.visibility,
+      avatarUrl: rawGroupItem.avatar_url,
+      relativePath: rawGroupItem.relative_path,
+      editPath: rawGroupItem.edit_path,
+      leavePath: rawGroupItem.leave_path,
+      canEdit: rawGroupItem.can_edit,
+      canLeave: rawGroupItem.can_leave,
+      type: rawGroupItem.type,
+      permission: rawGroupItem.permission,
+      children: groupChildren,
+      isOpen: groupIsOpen,
+      isChildrenLoading: false,
+      isBeingRemoved: false,
+      parentId: rawGroupItem.parent_id,
+      childrenCount,
+      projectCount: rawGroupItem.project_count,
+      subgroupCount: rawGroupItem.subgroup_count,
+      memberCount: rawGroupItem.number_users_with_delimiter,
+      starCount: rawGroupItem.star_count,
+    };
+  }
+
+  removeGroup(group, parentGroup) {
+    const updatedParentGroup = parentGroup;
+    if (updatedParentGroup.children && updatedParentGroup.children.length) {
+      updatedParentGroup.children = parentGroup.children.filter(child => group.id !== child.id);
+    } else {
+      this.state.groups = this.state.groups.filter(child => group.id !== child.id);
+    }
+  }
+}
diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js
deleted file mode 100644
index f59ec677603d1746c25549bccdf30ac3077e36f8..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/groups/stores/groups_store.js
+++ /dev/null
@@ -1,167 +0,0 @@
-import Vue from 'vue';
-import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
-
-export default class GroupsStore {
-  constructor() {
-    this.state = {};
-    this.state.groups = {};
-    this.state.pageInfo = {};
-  }
-
-  setGroups(rawGroups, parent) {
-    const parentGroup = parent;
-    const tree = this.buildTree(rawGroups, parentGroup);
-
-    if (parentGroup) {
-      parentGroup.subGroups = tree;
-    } else {
-      this.state.groups = tree;
-    }
-
-    return tree;
-  }
-
-  // eslint-disable-next-line class-methods-use-this
-  resetGroups(parent) {
-    const parentGroup = parent;
-    parentGroup.subGroups = {};
-  }
-
-  storePagination(pagination = {}) {
-    let paginationInfo;
-
-    if (Object.keys(pagination).length) {
-      const normalizedHeaders = normalizeHeaders(pagination);
-      paginationInfo = parseIntPagination(normalizedHeaders);
-    } else {
-      paginationInfo = pagination;
-    }
-
-    this.state.pageInfo = paginationInfo;
-  }
-
-  buildTree(rawGroups, parentGroup) {
-    const groups = this.decorateGroups(rawGroups);
-    const tree = {};
-    const mappedGroups = {};
-    const orphans = [];
-
-    // Map groups to an object
-    groups.map((group) => {
-      mappedGroups[`id${group.id}`] = group;
-      mappedGroups[`id${group.id}`].subGroups = {};
-      return group;
-    });
-
-    Object.keys(mappedGroups).map((key) => {
-      const currentGroup = mappedGroups[key];
-      if (currentGroup.parentId) {
-        // If the group is not at the root level, add it to its parent array of subGroups.
-        const findParentGroup = mappedGroups[`id${currentGroup.parentId}`];
-        if (findParentGroup) {
-          mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup;
-          mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups
-        } else if (parentGroup && parentGroup.id === currentGroup.parentId) {
-          tree[`id${currentGroup.id}`] = currentGroup;
-        } else {
-          // No parent found. We save it for later processing
-          orphans.push(currentGroup);
-
-          // Add to tree to preserve original order
-          tree[`id${currentGroup.id}`] = currentGroup;
-        }
-      } else {
-        // If the group is at the top level, add it to first level elements array.
-        tree[`id${currentGroup.id}`] = currentGroup;
-      }
-
-      return key;
-    });
-
-    if (orphans.length) {
-      orphans.map((orphan) => {
-        let found = false;
-        const currentOrphan = orphan;
-
-        Object.keys(tree).map((key) => {
-          const group = tree[key];
-
-          if (
-           group &&
-           currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 &&
-           // Make sure the currently selected orphan is not the same as the group
-           // we are checking here otherwise it will end up in an infinite loop
-           currentOrphan.id !== group.id
-           ) {
-            group.subGroups[currentOrphan.id] = currentOrphan;
-            group.isOpen = true;
-            currentOrphan.isOrphan = true;
-            found = true;
-
-            // Delete if group was put at the top level. If not the group will be displayed twice.
-            if (tree[`id${currentOrphan.id}`]) {
-              delete tree[`id${currentOrphan.id}`];
-            }
-          }
-
-          return key;
-        });
-
-        if (!found) {
-          currentOrphan.isOrphan = true;
-
-          tree[`id${currentOrphan.id}`] = currentOrphan;
-        }
-
-        return orphan;
-      });
-    }
-
-    return tree;
-  }
-
-  decorateGroups(rawGroups) {
-    this.groups = rawGroups.map(this.decorateGroup);
-    return this.groups;
-  }
-
-  // eslint-disable-next-line class-methods-use-this
-  decorateGroup(rawGroup) {
-    return {
-      id: rawGroup.id,
-      fullName: rawGroup.full_name,
-      fullPath: rawGroup.full_path,
-      avatarUrl: rawGroup.avatar_url,
-      name: rawGroup.name,
-      hasSubgroups: rawGroup.has_subgroups,
-      canEdit: rawGroup.can_edit,
-      description: rawGroup.description,
-      webUrl: rawGroup.web_url,
-      groupPath: rawGroup.group_path,
-      parentId: rawGroup.parent_id,
-      visibility: rawGroup.visibility,
-      leavePath: rawGroup.leave_path,
-      editPath: rawGroup.edit_path,
-      isOpen: false,
-      isOrphan: false,
-      numberProjects: rawGroup.number_projects_with_delimiter,
-      numberUsers: rawGroup.number_users_with_delimiter,
-      permissions: {
-        humanGroupAccess: rawGroup.permissions.human_group_access,
-      },
-      subGroups: {},
-    };
-  }
-
-  // eslint-disable-next-line class-methods-use-this
-  removeGroup(group, collection) {
-    Vue.delete(collection, `id${group.id}`);
-  }
-
-  // eslint-disable-next-line class-methods-use-this
-  toggleSubGroups(toggleGroup) {
-    const group = toggleGroup;
-    group.isOpen = !group.isOpen;
-    return group;
-  }
-}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index eecb56cb185165aef9c436402e0901bda112c659..d1aa83ea57fba595cc2c5e1ba259a5c1207bd626 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -24,6 +24,11 @@ export default {
       required: true,
       type: Boolean,
     },
+    showInlineEditButton: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
     issuableRef: {
       type: String,
       required: true,
@@ -222,20 +227,25 @@ export default {
     <div v-else>
       <title-component
         :issuable-ref="issuableRef"
+        :can-update="canUpdate"
         :title-html="state.titleHtml"
-        :title-text="state.titleText" />
+        :title-text="state.titleText"
+        :show-inline-edit-button="showInlineEditButton"
+      />
       <description-component
         v-if="state.descriptionHtml"
         :can-update="canUpdate"
         :description-html="state.descriptionHtml"
         :description-text="state.descriptionText"
         :updated-at="state.updatedAt"
-        :task-status="state.taskStatus" />
+        :task-status="state.taskStatus"
+      />
       <edited-component
         v-if="hasUpdated"
         :updated-at="state.updatedAt"
         :updated-by-name="state.updatedByName"
-        :updated-by-path="state.updatedByPath" />
+        :updated-by-path="state.updatedByPath"
+      />
     </div>
   </div>
 </template>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
index a9dabd4cff1a72a822943ebc45d81d79759b2085..00002709ac677efd1d207b0d19d973dc3d7277b3 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -1,5 +1,8 @@
 <script>
   import animateMixin from '../mixins/animate';
+  import eventHub from '../event_hub';
+  import tooltip from '../../vue_shared/directives/tooltip';
+  import { spriteIcon } from '../../lib/utils/common_utils';
 
   export default {
     mixins: [animateMixin],
@@ -15,6 +18,11 @@
         type: String,
         required: true,
       },
+      canUpdate: {
+        required: false,
+        type: Boolean,
+        default: false,
+      },
       titleHtml: {
         type: String,
         required: true,
@@ -23,6 +31,14 @@
         type: String,
         required: true,
       },
+      showInlineEditButton: {
+        type: Boolean,
+        required: false,
+        default: false,
+      },
+    },
+    directives: {
+      tooltip,
     },
     watch: {
       titleHtml() {
@@ -30,24 +46,46 @@
         this.animateChange();
       },
     },
+    computed: {
+      pencilIcon() {
+        return spriteIcon('pencil', 'link-highlight');
+      },
+    },
     methods: {
       setPageTitle() {
         const currentPageTitleScope = this.titleEl.innerText.split('·');
         currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
         this.titleEl.textContent = currentPageTitleScope.join('·');
       },
+      edit() {
+        eventHub.$emit('open.form');
+      },
     },
   };
 </script>
 
 <template>
-  <h2
-    class="title"
-    :class="{
-      'issue-realtime-pre-pulse': preAnimation,
-      'issue-realtime-trigger-pulse': pulseAnimation
-    }"
-    v-html="titleHtml"
-  >
-  </h2>
+  <div class="title-container">
+    <h2
+      class="title"
+      :class="{
+        'issue-realtime-pre-pulse': preAnimation,
+        'issue-realtime-trigger-pulse': pulseAnimation
+      }"
+      v-html="titleHtml"
+    >
+    </h2>
+    <button
+      v-tooltip
+      v-if="showInlineEditButton && canUpdate"
+      type="button"
+      class="btn-blank btn-edit note-action-button"
+      v-html="pencilIcon"
+      title="Edit title and description"
+      data-placement="bottom"
+      data-container="body"
+      @click="edit"
+      >
+    </button>
+  </div>
 </template>
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 9f05cf16967e867cea90831846f94df7652b0524..07899777a1e0f6c472f17633bcca34bfbc02afc5 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -403,7 +403,11 @@ export const setCiStatusFavicon = (pageUrl) => {
   });
 };
 
-export const spriteIcon = icon => `<svg><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`;
+export const spriteIcon = (icon, className = '') => {
+  const classAttribute = className.length > 0 ? `class="${className}"` : '';
+
+  return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`;
+};
 
 export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
 
diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue
index cc60aa5939c053e35320ca06113235a2224dc0a3..0a89a9f16cb57093b7cc77df6a71c635cdee3073 100644
--- a/app/assets/javascripts/repo/components/repo.vue
+++ b/app/assets/javascripts/repo/components/repo.vue
@@ -11,7 +11,9 @@ import Helper from '../helpers/repo_helper';
 import MonacoLoaderHelper from '../helpers/monaco_loader_helper';
 
 export default {
-  data: () => Store,
+  data() {
+    return Store;
+  },
   mixins: [RepoMixin],
   components: {
     RepoSidebar,
diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue
index c0dc4c8cd8b8d74b57a41761d69e70c32d0787ae..185cd90ac066476bb49b34c6b166274580851d19 100644
--- a/app/assets/javascripts/repo/components/repo_commit_section.vue
+++ b/app/assets/javascripts/repo/components/repo_commit_section.vue
@@ -9,7 +9,9 @@ import { visitUrl } from '../../lib/utils/url_utility';
 export default {
   mixins: [RepoMixin],
 
-  data: () => Store,
+  data() {
+    return Store;
+  },
 
   components: {
     PopupDialog,
diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue
index 353142edeb70260691467dcb41fcc0e131891407..e6e8b2e5205297525717e7edc247ee573178765a 100644
--- a/app/assets/javascripts/repo/components/repo_edit_button.vue
+++ b/app/assets/javascripts/repo/components/repo_edit_button.vue
@@ -3,7 +3,9 @@ import Store from '../stores/repo_store';
 import RepoMixin from '../mixins/repo_mixin';
 
 export default {
-  data: () => Store,
+  data() {
+    return Store;
+  },
   mixins: [RepoMixin],
   computed: {
     buttonLabel() {
diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue
index 02d9c775046b3f1b9c1bae456f7f3d50edaf9eb9..4639bee6d662eb654122a0a2c02d2d2bcafb7447 100644
--- a/app/assets/javascripts/repo/components/repo_editor.vue
+++ b/app/assets/javascripts/repo/components/repo_editor.vue
@@ -5,7 +5,9 @@ import Service from '../services/repo_service';
 import Helper from '../helpers/repo_helper';
 
 const RepoEditor = {
-  data: () => Store,
+  data() {
+    return Store;
+  },
 
   destroyed() {
     if (Helper.monacoInstance) {
@@ -22,7 +24,8 @@ const RepoEditor = {
         const monacoInstance = Helper.monaco.editor.create(this.$el, {
           model: null,
           readOnly: false,
-          contextmenu: false,
+          contextmenu: true,
+          scrollBeyondLastLine: false,
         });
 
         Helper.monacoInstance = monacoInstance;
@@ -92,7 +95,7 @@ const RepoEditor = {
     },
 
     blobRaw() {
-      if (Helper.monacoInstance && !this.isTree) {
+      if (Helper.monacoInstance) {
         this.setupEditor();
       }
     },
diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue
index 8b9cbd234560db6dbbd4bd8d1a1678aa235492fd..c7e69340f17d1cc0b03015b74c1534868cd9a419 100644
--- a/app/assets/javascripts/repo/components/repo_file.vue
+++ b/app/assets/javascripts/repo/components/repo_file.vue
@@ -1,107 +1,78 @@
 <script>
-import TimeAgoMixin from '../../vue_shared/mixins/timeago';
+  import timeAgoMixin from '../../vue_shared/mixins/timeago';
+  import eventHub from '../event_hub';
+  import repoMixin from '../mixins/repo_mixin';
 
-const RepoFile = {
-  mixins: [TimeAgoMixin],
-  props: {
-    file: {
-      type: Object,
-      required: true,
+  export default {
+    mixins: [
+      repoMixin,
+      timeAgoMixin,
+    ],
+    props: {
+      file: {
+        type: Object,
+        required: true,
+      },
     },
-    isMini: {
-      type: Boolean,
-      required: false,
-      default: false,
+    computed: {
+      fileIcon() {
+        const classObj = {
+          'fa-spinner fa-spin': this.file.loading,
+          [this.file.icon]: !this.file.loading,
+          'fa-folder-open': !this.file.loading && this.file.opened,
+        };
+        return classObj;
+      },
+      levelIndentation() {
+        return {
+          marginLeft: `${this.file.level * 16}px`,
+        };
+      },
     },
-    loading: {
-      type: Object,
-      required: false,
-      default() { return { tree: false }; },
+    methods: {
+      linkClicked(file) {
+        eventHub.$emit('fileNameClicked', file);
+      },
     },
-    hasFiles: {
-      type: Boolean,
-      required: false,
-      default: false,
-    },
-    activeFile: {
-      type: Object,
-      required: true,
-    },
-  },
-
-  computed: {
-    canShowFile() {
-      return !this.loading.tree || this.hasFiles;
-    },
-
-    fileIcon() {
-      const classObj = {
-        'fa-spinner fa-spin': this.file.loading,
-        [this.file.icon]: !this.file.loading,
-      };
-      return classObj;
-    },
-
-    fileIndentation() {
-      return {
-        'margin-left': `${this.file.level * 10}px`,
-      };
-    },
-
-    activeFileClass() {
-      return {
-        active: this.activeFile.url === this.file.url,
-      };
-    },
-  },
-
-  methods: {
-    linkClicked(file) {
-      this.$emit('linkclicked', file);
-    },
-  },
-};
-
-export default RepoFile;
+  };
 </script>
 
 <template>
-<tr
-  v-if="canShowFile"
-  class="file"
-  :class="activeFileClass"
-  @click.prevent="linkClicked(file)">
-  <td>
-    <i
-      class="fa fa-fw file-icon"
-      :class="fileIcon"
-      :style="fileIndentation"
-      aria-label="file icon">
-    </i>
-    <a
-      :href="file.url"
-      class="repo-file-name"
-      :title="file.url">
-      {{file.name}}
-    </a>
-  </td>
+  <tr
+    class="file"
+    @click.prevent="linkClicked(file)">
+    <td>
+      <i
+        class="fa fa-fw file-icon"
+        :class="fileIcon"
+        :style="levelIndentation"
+        aria-hidden="true"
+      >
+      </i>
+      <a
+        :href="file.url"
+        class="repo-file-name"
+      >
+        {{ file.name }}
+      </a>
+    </td>
 
-  <template v-if="!isMini">
-    <td class="hidden-sm hidden-xs">
-      <div class="commit-message">
-        <a @click.stop :href="file.lastCommitUrl">
-          {{file.lastCommitMessage}}
+    <template v-if="!isMini">
+      <td class="hidden-sm hidden-xs">
+        <a
+          @click.stop
+          :href="file.lastCommit.url"
+          class="commit-message"
+        >
+          {{ file.lastCommit.message }}
         </a>
-      </div>
-    </td>
+      </td>
 
-    <td class="hidden-xs text-right">
-      <span
-        class="commit-update"
-        :title="tooltipTitle(file.lastCommitUpdate)">
-        {{timeFormated(file.lastCommitUpdate)}}
-      </span>
-    </td>
-  </template>
-</tr>
+      <td class="commit-update hidden-xs text-right">
+        <span :title="tooltipTitle(file.lastCommit.updatedAt)">
+          {{ timeFormated(file.lastCommit.updatedAt) }}
+        </span>
+      </td>
+    </template>
+  </tr>
 </template>
diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue
index e43ef366f47283d2b7d794091bef9fb47848ccc3..03cd219e7184f80685ba33c3a91d32f3329d8059 100644
--- a/app/assets/javascripts/repo/components/repo_file_buttons.vue
+++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue
@@ -4,7 +4,9 @@ import Helper from '../helpers/repo_helper';
 import RepoMixin from '../mixins/repo_mixin';
 
 const RepoFileButtons = {
-  data: () => Store,
+  data() {
+    return Store;
+  },
 
   mixins: [RepoMixin],
 
diff --git a/app/assets/javascripts/repo/components/repo_file_options.vue b/app/assets/javascripts/repo/components/repo_file_options.vue
deleted file mode 100644
index 6a15755f029da4068726c29273c2c4bc310f1a1a..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/repo/components/repo_file_options.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<script>
-const RepoFileOptions = {
-  props: {
-    isMini: {
-      type: Boolean,
-      required: false,
-      default: false,
-    },
-    projectName: {
-      type: String,
-      required: true,
-    },
-  },
-};
-
-export default RepoFileOptions;
-</script>
-
-<template>
-  <tr v-if="isMini" class="repo-file-options">
-    <td>
-      <span class="title">{{projectName}}</span>
-    </td>
-  </tr>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue
index bc8c64c8362b1e44d0b9e507040573eb6b07eb7d..832b45b2b2944f5f30ef26d02d78f733f721e562 100644
--- a/app/assets/javascripts/repo/components/repo_loading_file.vue
+++ b/app/assets/javascripts/repo/components/repo_loading_file.vue
@@ -1,43 +1,23 @@
 <script>
-const RepoLoadingFile = {
-  props: {
-    loading: {
-      type: Object,
-      required: false,
-      default: {},
-    },
-    hasFiles: {
-      type: Boolean,
-      required: false,
-      default: false,
-    },
-    isMini: {
-      type: Boolean,
-      required: false,
-      default: false,
-    },
-  },
-
-  computed: {
-    showGhostLines() {
-      return this.loading.tree && !this.hasFiles;
-    },
-  },
+  import repoMixin from '../mixins/repo_mixin';
 
-  methods: {
-    lineOfCode(n) {
-      return `skeleton-line-${n}`;
+  export default {
+    mixins: [
+      repoMixin,
+    ],
+    methods: {
+      lineOfCode(n) {
+        return `skeleton-line-${n}`;
+      },
     },
-  },
-};
-
-export default RepoLoadingFile;
+  };
 </script>
 
 <template>
   <tr
-    v-if="showGhostLines"
-    class="loading-file">
+    class="loading-file"
+    aria-label="Loading files"
+  >
     <td>
       <div
         class="animation-container animation-container-small">
@@ -48,29 +28,28 @@ export default RepoLoadingFile;
         </div>
       </div>
     </td>
-
-    <td
-      v-if="!isMini"
-      class="hidden-sm hidden-xs">
-      <div class="animation-container">
-        <div
-          v-for="n in 6"
-          :key="n"
-          :class="lineOfCode(n)">
+    <template v-if="!isMini">
+      <td
+        class="hidden-sm hidden-xs">
+        <div class="animation-container">
+          <div
+            v-for="n in 6"
+            :key="n"
+            :class="lineOfCode(n)">
+          </div>
         </div>
-      </div>
-    </td>
+      </td>
 
-    <td
-      v-if="!isMini"
-      class="hidden-xs">
-      <div class="animation-container animation-container-small">
-        <div
-          v-for="n in 6"
-          :key="n"
-          :class="lineOfCode(n)">
+      <td
+        class="hidden-xs">
+        <div class="animation-container animation-container-small animation-container-right">
+          <div
+            v-for="n in 6"
+            :key="n"
+            :class="lineOfCode(n)">
+          </div>
         </div>
-      </div>
-    </td>
+      </td>
+    </template>
   </tr>
 </template>
diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue
index bbdbdc61e38f364554727e7fbea2cc3e3804f6da..c4bf6dcdec2087de45a0d33ba8ea04a7935e4c7b 100644
--- a/app/assets/javascripts/repo/components/repo_prev_directory.vue
+++ b/app/assets/javascripts/repo/components/repo_prev_directory.vue
@@ -1,38 +1,38 @@
 <script>
-import RepoMixin from '../mixins/repo_mixin';
+  import eventHub from '../event_hub';
+  import repoMixin from '../mixins/repo_mixin';
 
-const RepoPreviousDirectory = {
-  props: {
-    prevUrl: {
-      type: String,
-      required: true,
+  export default {
+    mixins: [
+      repoMixin,
+    ],
+    props: {
+      prevUrl: {
+        type: String,
+        required: true,
+      },
     },
-  },
-
-  mixins: [RepoMixin],
-
-  computed: {
-    colSpanCondition() {
-      return this.isMini ? undefined : 3;
+    computed: {
+      colSpanCondition() {
+        return this.isMini ? undefined : 3;
+      },
     },
-  },
-
-  methods: {
-    linkClicked(file) {
-      this.$emit('linkclicked', file);
+    methods: {
+      linkClicked(file) {
+        eventHub.$emit('goToPreviousDirectoryClicked', file);
+      },
     },
-  },
-};
-
-export default RepoPreviousDirectory;
+  };
 </script>
 
 <template>
-<tr class="prev-directory">
-  <td
-    :colspan="colSpanCondition"
-    @click.prevent="linkClicked(prevUrl)">
-    <a :href="prevUrl">..</a>
-  </td>
-</tr>
+  <tr class="file prev-directory">
+    <td
+      :colspan="colSpanCondition"
+      class="table-cell"
+      @click.prevent="linkClicked(prevUrl)"
+    >
+      <a :href="prevUrl">...</a>
+    </td>
+  </tr>
 </template>
diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue
index a87bef6084a7357ca19dd9b1a841828d9a874c5c..b5be771d539e568188d9f069d51692281c7a8b3f 100644
--- a/app/assets/javascripts/repo/components/repo_preview.vue
+++ b/app/assets/javascripts/repo/components/repo_preview.vue
@@ -4,7 +4,9 @@
 import Store from '../stores/repo_store';
 
 export default {
-  data: () => Store,
+  data() {
+    return Store;
+  },
   computed: {
     html() {
       return this.activeFile.html;
diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue
index e0f3c33003a5f86ca45b124942db80ebd8d6e429..5832e6039071ac0afd85550da86c3a694ce995cb 100644
--- a/app/assets/javascripts/repo/components/repo_sidebar.vue
+++ b/app/assets/javascripts/repo/components/repo_sidebar.vue
@@ -1,9 +1,10 @@
 <script>
+import _ from 'underscore';
 import Service from '../services/repo_service';
 import Helper from '../helpers/repo_helper';
 import Store from '../stores/repo_store';
+import eventHub from '../event_hub';
 import RepoPreviousDirectory from './repo_prev_directory.vue';
-import RepoFileOptions from './repo_file_options.vue';
 import RepoFile from './repo_file.vue';
 import RepoLoadingFile from './repo_loading_file.vue';
 import RepoMixin from '../mixins/repo_mixin';
@@ -11,21 +12,35 @@ import RepoMixin from '../mixins/repo_mixin';
 export default {
   mixins: [RepoMixin],
   components: {
-    'repo-file-options': RepoFileOptions,
     'repo-previous-directory': RepoPreviousDirectory,
     'repo-file': RepoFile,
     'repo-loading-file': RepoLoadingFile,
   },
-
   created() {
     window.addEventListener('popstate', this.checkHistory);
   },
   destroyed() {
+    eventHub.$off('fileNameClicked', this.fileClicked);
+    eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
     window.removeEventListener('popstate', this.checkHistory);
   },
+  mounted() {
+    eventHub.$on('fileNameClicked', this.fileClicked);
+    eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
+  },
+  data() {
+    return Store;
+  },
+  computed: {
+    flattendFiles() {
+      const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)]));
 
-  data: () => Store,
-
+      return _.chain(this.files)
+        .map(arr => [arr, mapFiles(arr)])
+        .flatten()
+        .value();
+    },
+  },
   methods: {
     checkHistory() {
       let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1);
@@ -52,21 +67,21 @@ export default {
     },
 
     fileClicked(clickedFile, lineNumber) {
-      let file = clickedFile;
+      const file = clickedFile;
+
       if (file.loading) return;
-      file.loading = true;
 
       if (file.type === 'tree' && file.opened) {
-        file = Store.removeChildFilesOfTree(file);
-        file.loading = false;
+        Helper.setDirectoryToClosed(file);
         Store.setActiveLine(lineNumber);
       } else {
         const openFile = Helper.getFileFromPath(file.url);
+
         if (openFile) {
-          file.loading = false;
           Store.setActiveFiles(openFile);
           Store.setActiveLine(lineNumber);
         } else {
+          file.loading = true;
           Service.url = file.url;
           Helper.getContent(file)
             .then(() => {
@@ -81,7 +96,7 @@ export default {
 
     goToPreviousDirectoryClicked(prevURL) {
       Service.url = prevURL;
-      Helper.getContent(null)
+      Helper.getContent(null, true)
         .then(() => Helper.scrollTabsRight())
         .catch(Helper.loadingError);
     },
@@ -92,38 +107,43 @@ export default {
 <template>
 <div id="sidebar" :class="{'sidebar-mini' : isMini}">
   <table class="table">
-    <thead v-if="!isMini">
+    <thead>
       <tr>
-        <th class="name">Name</th>
-        <th class="hidden-sm hidden-xs last-commit">Last commit</th>
-        <th class="hidden-xs last-update text-right">Last update</th>
+        <th
+          v-if="isMini"
+          class="repo-file-options title"
+        >
+          <strong class="clgray">
+            {{ projectName }}
+          </strong>
+        </th>
+        <template v-else>
+          <th class="name">
+            Name
+          </th>
+          <th class="hidden-sm hidden-xs last-commit">
+            Last commit
+          </th>
+          <th class="hidden-xs last-update text-right">
+            Last update
+          </th>
+        </template>
       </tr>
     </thead>
     <tbody>
-      <repo-file-options
-        :is-mini="isMini"
-        :project-name="projectName"
-      />
       <repo-previous-directory
-        v-if="isRoot"
+        v-if="!isRoot && !loading.tree"
         :prev-url="prevURL"
-        @linkclicked="goToPreviousDirectoryClicked(prevURL)"/>
+      />
       <repo-loading-file
+        v-if="!flattendFiles.length && loading.tree"
         v-for="n in 5"
         :key="n"
-        :loading="loading"
-        :has-files="!!files.length"
-        :is-mini="isMini"
       />
       <repo-file
-        v-for="file in files"
+        v-for="file in flattendFiles"
         :key="file.id"
         :file="file"
-        :is-mini="isMini"
-        @linkclicked="fileClicked(file)"
-        :is-tree="isTree"
-        :has-files="!!files.length"
-        :active-file="activeFile"
       />
     </tbody>
   </table>
diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue
index 0d0c34ec741f19d5eabac058cb1e17e6b78426f6..098715915b080e061ea478149ba6b036b0e98a46 100644
--- a/app/assets/javascripts/repo/components/repo_tab.vue
+++ b/app/assets/javascripts/repo/components/repo_tab.vue
@@ -26,11 +26,13 @@ const RepoTab = {
   },
 
   methods: {
-    tabClicked: Store.setActiveFiles,
-
+    tabClicked(file) {
+      Store.setActiveFiles(file);
+    },
     closeTab(file) {
       if (file.changed) return;
-      this.$emit('tabclosed', file);
+
+      Store.removeFromOpenedFiles(file);
     },
   },
 };
@@ -39,25 +41,28 @@ export default RepoTab;
 </script>
 
 <template>
-<li @click="tabClicked(tab)">
-  <a
-    href="#0"
-    class="close"
-    @click.stop.prevent="closeTab(tab)"
-    :aria-label="closeLabel">
-    <i
-      class="fa"
-      :class="changedClass"
-      aria-hidden="true">
-    </i>
-  </a>
+  <li
+    :class="{ active : tab.active }"
+    @click="tabClicked(tab)"
+  >
+    <button
+      type="button"
+      class="close-btn"
+      @click.stop.prevent="closeTab(tab)"
+      :aria-label="closeLabel">
+      <i
+        class="fa"
+        :class="changedClass"
+        aria-hidden="true">
+      </i>
+    </button>
 
-  <a
-    href="#"
-    class="repo-tab"
-    :title="tab.url"
-    @click.prevent="tabClicked(tab)">
-    {{tab.name}}
-  </a>
-</li>
+    <a
+      href="#"
+      class="repo-tab"
+      :title="tab.url"
+      @click.prevent="tabClicked(tab)">
+      {{tab.name}}
+    </a>
+  </li>
 </template>
diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue
index 9c5bfc5d0cfad915674b5ada8d86fde4bac7ee70..b57cd0960dec24d0ea3177a3b56d15fb39559f40 100644
--- a/app/assets/javascripts/repo/components/repo_tabs.vue
+++ b/app/assets/javascripts/repo/components/repo_tabs.vue
@@ -1,36 +1,29 @@
 <script>
-import Store from '../stores/repo_store';
-import RepoTab from './repo_tab.vue';
-import RepoMixin from '../mixins/repo_mixin';
+  import Store from '../stores/repo_store';
+  import RepoTab from './repo_tab.vue';
+  import RepoMixin from '../mixins/repo_mixin';
 
-const RepoTabs = {
-  mixins: [RepoMixin],
-
-  components: {
-    'repo-tab': RepoTab,
-  },
-
-  data: () => Store,
-
-  methods: {
-    tabClosed(file) {
-      Store.removeFromOpenedFiles(file);
+  export default {
+    mixins: [RepoMixin],
+    components: {
+      'repo-tab': RepoTab,
     },
-  },
-};
-
-export default RepoTabs;
+    data() {
+      return Store;
+    },
+  };
 </script>
 
 <template>
-<ul id="tabs">
-  <repo-tab
-    v-for="tab in openedFiles"
-    :key="tab.id"
-    :tab="tab"
-    :class="{'active' : tab.active}"
-    @tabclosed="tabClosed"
-  />
-  <li class="tabs-divider" />
-</ul>
+  <ul
+    id="tabs"
+    class="list-unstyled"
+  >
+    <repo-tab
+      v-for="tab in openedFiles"
+      :key="tab.id"
+      :tab="tab"
+    />
+    <li class="tabs-divider" />
+  </ul>
 </template>
diff --git a/app/assets/javascripts/repo/event_hub.js b/app/assets/javascripts/repo/event_hub.js
new file mode 100644
index 0000000000000000000000000000000000000000..0948c2e53524a736a55c060600868ce89ee7687a
--- /dev/null
+++ b/app/assets/javascripts/repo/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js
index 46204598e1d88e884e4cee10531565bc5f2453c6..dfaf9caaee78ead30f3b414694f0ffb457d675e3 100644
--- a/app/assets/javascripts/repo/helpers/repo_helper.js
+++ b/app/assets/javascripts/repo/helpers/repo_helper.js
@@ -1,3 +1,4 @@
+import { convertPermissionToBoolean } from '../../lib/utils/common_utils';
 import Service from '../services/repo_service';
 import Store from '../stores/repo_store';
 import Flash from '../../flash';
@@ -25,10 +26,6 @@ const RepoHelper = {
 
   key: '',
 
-  isTree(data) {
-    return Object.hasOwnProperty.call(data, 'blobs');
-  },
-
   Time: window.performance
   && window.performance.now
   ? window.performance
@@ -58,13 +55,20 @@ const RepoHelper = {
   },
 
   setDirectoryOpen(tree, title) {
-    const file = tree;
-    if (!file) return undefined;
+    if (!tree) return;
+
+    Object.assign(tree, {
+      opened: true,
+    });
+
+    RepoHelper.updateHistoryEntry(tree.url, title);
+  },
 
-    file.opened = true;
-    file.icon = 'fa-folder-open';
-    RepoHelper.updateHistoryEntry(file.url, title);
-    return file;
+  setDirectoryToClosed(entry) {
+    Object.assign(entry, {
+      opened: false,
+      files: [],
+    });
   },
 
   isRenderable() {
@@ -81,63 +85,23 @@ const RepoHelper = {
     .catch(RepoHelper.loadingError);
   },
 
-  // when you open a directory you need to put the directory files under
-  // the directory... This will merge the list of the current directory and the new list.
-  getNewMergedList(inDirectory, currentList, newList) {
-    const newListSorted = newList.sort(this.compareFilesCaseInsensitive);
-    if (!inDirectory) return newListSorted;
-    const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url);
-    if (!indexOfFile) return newListSorted;
-    return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile);
-  },
-
-  // within the get new merged list this does the merging of the current list of files
-  // and the new list of files. The files are never "in" another directory they just
-  // appear like they are because of the margin.
-  mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) {
-    newList.reverse().forEach((newFile) => {
-      const fileIndex = indexOfFile + 1;
-      const file = newFile;
-      file.level = inDirectory.level + 1;
-      oldList.splice(fileIndex, 0, file);
-    });
-
-    return oldList;
-  },
-
-  compareFilesCaseInsensitive(a, b) {
-    const aName = a.name.toLowerCase();
-    const bName = b.name.toLowerCase();
-    if (a.level > 0) return 0;
-    if (aName < bName) { return -1; }
-    if (aName > bName) { return 1; }
-    return 0;
-  },
+  getContent(treeOrFile, emptyFiles = false) {
+    let file = treeOrFile;
 
-  isRoot(url) {
-    // the url we are requesting -> split by the project URL. Grab the right side.
-    const isRoot = !!url.split(Store.projectUrl)[1]
-    // remove the first "/"
-    .slice(1)
-    // split this by "/"
-    .split('/')
-    // remove the first two items of the array... usually /tree/master.
-    .slice(2)
-    // we want to know the length of the array.
-    // If greater than 0 not root.
-    .length;
-    return isRoot;
-  },
+    if (!Store.files.length) {
+      Store.loading.tree = true;
+    }
 
-  getContent(treeOrFile) {
-    let file = treeOrFile;
     return Service.getContent()
     .then((response) => {
       const data = response.data;
       if (response.headers && response.headers['page-title']) data.pageTitle = response.headers['page-title'];
+      if (response.headers && response.headers['is-root'] && !Store.isInitialRoot) {
+        Store.isRoot = convertPermissionToBoolean(response.headers['is-root']);
+        Store.isInitialRoot = Store.isRoot;
+      }
 
-      Store.isTree = RepoHelper.isTree(data);
-      if (!Store.isTree) {
+      if (file && file.type === 'blob') {
         if (!file) file = data;
         Store.binary = data.binary;
 
@@ -145,38 +109,40 @@ const RepoHelper = {
           // file might be undefined
           RepoHelper.setBinaryDataAsBase64(data);
           Store.setViewToPreview();
-        } else if (!Store.isPreviewView()) {
-          if (!data.render_error) {
-            Service.getRaw(data.raw_path)
-            .then((rawResponse) => {
-              Store.blobRaw = rawResponse.data;
-              data.plain = rawResponse.data;
-              RepoHelper.setFile(data, file);
-            }).catch(RepoHelper.loadingError);
-          }
+        } else if (!Store.isPreviewView() && !data.render_error) {
+          Service.getRaw(data.raw_path)
+          .then((rawResponse) => {
+            Store.blobRaw = rawResponse.data;
+            data.plain = rawResponse.data;
+            RepoHelper.setFile(data, file);
+          }).catch(RepoHelper.loadingError);
         }
 
         if (Store.isPreviewView()) {
           RepoHelper.setFile(data, file);
         }
+      } else {
+        Store.loading.tree = false;
+        RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
 
-        // if the file tree is empty
-        if (Store.files.length === 0) {
-          const parentURL = Service.blobURLtoParentTree(Service.url);
-          Service.url = parentURL;
-          RepoHelper.getContent();
+        if (emptyFiles) {
+          Store.files = [];
         }
-      } else {
-        // it's a tree
-        if (!file) Store.isRoot = RepoHelper.isRoot(Service.url);
-        file = RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
-        const newDirectory = RepoHelper.dataToListOfFiles(data);
-        Store.addFilesToDirectory(file, Store.files, newDirectory);
+
+        this.addToDirectory(file, data);
+
         Store.prevURL = Service.blobURLtoParentTree(Service.url);
       }
     }).catch(RepoHelper.loadingError);
   },
 
+  addToDirectory(file, data) {
+    const tree = file || Store;
+    const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0));
+
+    tree.files = files;
+  },
+
   setFile(data, file) {
     const newFile = data;
     newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh.
@@ -190,57 +156,39 @@ const RepoHelper = {
     Store.setActiveFiles(newFile);
   },
 
-  serializeBlob(blob) {
-    const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob);
-    simpleBlob.lastCommitMessage = blob.last_commit.message;
-    simpleBlob.lastCommitUpdate = blob.last_commit.committed_date;
-    simpleBlob.loading = false;
-
-    return simpleBlob;
-  },
-
-  serializeTree(tree) {
-    return RepoHelper.serializeRepoEntity('tree', tree);
-  },
-
-  serializeSubmodule(submodule) {
-    return RepoHelper.serializeRepoEntity('submodule', submodule);
-  },
-
-  serializeRepoEntity(type, entity) {
+  serializeRepoEntity(type, entity, level = 0) {
     const { url, name, icon, last_commit } = entity;
-    const returnObj = {
+
+    return {
       type,
       name,
       url,
+      level,
       icon: `fa-${icon}`,
-      level: 0,
+      files: [],
       loading: false,
+      opened: false,
+      // eslint-disable-next-line camelcase
+      lastCommit: last_commit ? {
+        url: `${Store.projectUrl}/commit/${last_commit.id}`,
+        message: last_commit.message,
+        updatedAt: last_commit.committed_date,
+      } : {},
     };
-
-    if (entity.last_commit) {
-      returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`;
-    } else {
-      returnObj.lastCommitUrl = '';
-    }
-    return returnObj;
   },
 
   scrollTabsRight() {
-    // wait for the transition. 0.1 seconds.
-    setTimeout(() => {
-      const tabs = document.getElementById('tabs');
-      if (!tabs) return;
-      tabs.scrollLeft = tabs.scrollWidth;
-    }, 200);
+    const tabs = document.getElementById('tabs');
+    if (!tabs) return;
+    tabs.scrollLeft = tabs.scrollWidth;
   },
 
-  dataToListOfFiles(data) {
+  dataToListOfFiles(data, level) {
     const { blobs, trees, submodules } = data;
     return [
-      ...blobs.map(blob => RepoHelper.serializeBlob(blob)),
-      ...trees.map(tree => RepoHelper.serializeTree(tree)),
-      ...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)),
+      ...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)),
+      ...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)),
+      ...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)),
     ];
   },
 
diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js
index 1a09f411b2260d8742707b6fdd933335fa70558d..65dee7d5fd16a3c1120a898b2bfcdaf9a4d5b490 100644
--- a/app/assets/javascripts/repo/index.js
+++ b/app/assets/javascripts/repo/index.js
@@ -1,5 +1,6 @@
 import $ from 'jquery';
 import Vue from 'vue';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
 import Service from './services/repo_service';
 import Store from './stores/repo_store';
 import Repo from './components/repo.vue';
@@ -33,6 +34,8 @@ function setInitialStore(data) {
   Store.onTopOfBranch = data.onTopOfBranch;
   Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl);
   Store.customBranchURL = decodeURIComponent(data.blobUrl);
+  Store.isRoot = convertPermissionToBoolean(data.root);
+  Store.isInitialRoot = convertPermissionToBoolean(data.root);
   Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
   Store.checkIsCommitable();
   Store.setBranchHash();
diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js
index f8d29af7ffe4fbae34c579a09047aaf2a2ca6350..49d7317a17e69b0c06aef02e1184df7c41dae2cf 100644
--- a/app/assets/javascripts/repo/stores/repo_store.js
+++ b/app/assets/javascripts/repo/stores/repo_store.js
@@ -2,14 +2,13 @@ import Helper from '../helpers/repo_helper';
 import Service from '../services/repo_service';
 
 const RepoStore = {
-  monaco: {},
   monacoLoading: false,
   service: '',
   canCommit: false,
   onTopOfBranch: false,
   editMode: false,
-  isTree: false,
-  isRoot: false,
+  isRoot: null,
+  isInitialRoot: null,
   prevURL: '',
   projectId: '',
   projectName: '',
@@ -39,23 +38,11 @@ const RepoStore = {
   newMrTemplateUrl: '',
   branchChanged: false,
   commitMessage: '',
-  binaryTypes: {
-    png: false,
-    md: false,
-    svg: false,
-    unknown: false,
-  },
   loading: {
     tree: false,
     blob: false,
   },
 
-  resetBinaryTypes() {
-    Object.keys(RepoStore.binaryTypes).forEach((key) => {
-      RepoStore.binaryTypes[key] = false;
-    });
-  },
-
   setBranchHash() {
     return Service.getBranch()
       .then((data) => {
@@ -72,10 +59,6 @@ const RepoStore = {
     RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
   },
 
-  addFilesToDirectory(inDirectory, currentList, newList) {
-    RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList);
-  },
-
   toggleRawPreview() {
     RepoStore.activeFile.raw = !RepoStore.activeFile.raw;
     RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source';
@@ -129,30 +112,6 @@ const RepoStore = {
     RepoStore.activeFileLabel = 'Display source';
   },
 
-  removeChildFilesOfTree(tree) {
-    let foundTree = false;
-    const treeToClose = tree;
-    let canStopSearching = false;
-    RepoStore.files = RepoStore.files.filter((file) => {
-      const isItTheTreeWeWant = file.url === treeToClose.url;
-      // if it's the next tree
-      if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) {
-        canStopSearching = true;
-        return true;
-      }
-      if (canStopSearching) return true;
-
-      if (isItTheTreeWeWant) foundTree = true;
-
-      if (foundTree) return file.level <= treeToClose.level;
-      return true;
-    });
-
-    treeToClose.opened = false;
-    treeToClose.icon = 'fa-folder';
-    return treeToClose;
-  },
-
   removeFromOpenedFiles(file) {
     if (file.type === 'tree') return;
     let foundIndex;
@@ -186,6 +145,7 @@ const RepoStore = {
     if (openedFilesAlreadyExists) return;
 
     openFile.changed = false;
+    openFile.active = true;
     RepoStore.openedFiles.push(openFile);
   },
 
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 0d7a5cba9281b267aa056d9d2947b2826f73aa96..aa61ddc6a2c6e422692866cad53e60baeb27f128 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -7,6 +7,7 @@
 @import "framework/animations";
 @import "framework/avatar";
 @import "framework/asciidoctor";
+@import "framework/banner";
 @import "framework/blocks";
 @import "framework/buttons";
 @import "framework/badges";
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index f0e6b23757fcc1fb5aa862c9ac49ef35eddcfd48..374988bb59009fa3665add5281608608bbd73ce3 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -198,6 +198,13 @@ a {
     height: 12px;
   }
 
+  &.animation-container-right {
+    .skeleton-line-2 {
+      left: 0;
+      right: 150px;
+    }
+  }
+
   &::before {
     animation-duration: 1s;
     animation-fill-mode: forwards;
diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss
new file mode 100644
index 0000000000000000000000000000000000000000..6433b0c785574d38a44b8148139452793ec113cf
--- /dev/null
+++ b/app/assets/stylesheets/framework/banner.scss
@@ -0,0 +1,25 @@
+.banner-callout {
+  display: flex;
+  position: relative;
+  flex-wrap: wrap;
+
+  .banner-close {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    opacity: 1;
+
+    .dismiss-icon {
+      color: $gl-text-color;
+      font-size: $gl-font-size;
+    }
+  }
+
+  .banner-graphic {
+    margin: 20px auto;
+  }
+
+  &.banner-non-empty-state {
+    border-bottom: 1px solid $border-color;
+  }
+}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 588ec1ff3bc12423852ca81036f9e1ff055f1d9e..5833ef939e9fcfa982c71406426d970e4ec894a2 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -10,6 +10,10 @@
     border: 0;
   }
 
+  &.file-holder-bottom-radius {
+    border-radius: 0 0 $border-radius-small $border-radius-small;
+  }
+
   &.readme-holder {
     margin: $gl-padding 0;
 
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index badc7b0eba35697e74981f93b3e2b4cbf4ea509a..d43f998cb82ebd518c0ebf0ce7488a263e3feb1e 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -281,6 +281,57 @@ ul.indent-list {
 
 
 // Specific styles for tree list
+@keyframes spin-avatar {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+
+.groups-list-tree-container {
+  .has-no-search-results {
+    text-align: center;
+    padding: $gl-padding;
+    font-style: italic;
+    color: $well-light-text-color;
+  }
+
+  > .group-list-tree > .group-row.has-children:first-child {
+    border-top: none;
+  }
+}
+
+.group-list-tree .avatar-container.content-loading {
+  position: relative;
+
+  > a,
+  > a .avatar {
+    height: 100%;
+    border-radius: 50%;
+  }
+
+  > a {
+    padding: 2px;
+  }
+
+  > a .avatar {
+    border: 2px solid $white-normal;
+
+    &.identicon {
+      line-height: 30px;
+    }
+  }
+
+  &::after {
+    content: "";
+    position: absolute;
+    height: 100%;
+    width: 100%;
+    background-color: transparent;
+    border: 2px outset $kdb-border;
+    border-radius: 50%;
+    animation: spin-avatar 3s infinite linear;
+  }
+}
+
 .group-list-tree {
   .folder-toggle-wrap {
     float: left;
@@ -293,7 +344,7 @@ ul.indent-list {
   }
 
   .folder-caret,
-  .folder-icon {
+  .item-type-icon {
     display: inline-block;
   }
 
@@ -301,11 +352,11 @@ ul.indent-list {
     width: 15px;
   }
 
-  .folder-icon {
+  .item-type-icon {
     width: 20px;
   }
 
-  > .group-row:not(.has-subgroups) {
+  > .group-row:not(.has-children) {
     .folder-caret .fa {
       opacity: 0;
     }
@@ -351,12 +402,23 @@ ul.indent-list {
         top: 30px;
         bottom: 0;
       }
+
+      &.being-removed {
+        opacity: 0.5;
+      }
     }
   }
 
   .group-row {
     padding: 0;
-    border: none;
+
+    &.has-children {
+      border-top: none;
+    }
+
+    &:first-child {
+      border-top: 1px solid $white-normal;
+    }
 
     &:last-of-type {
       .group-row-contents:not(:hover) {
@@ -379,6 +441,25 @@ ul.indent-list {
     .avatar-container > a {
       width: 100%;
     }
+
+    &.has-more-items {
+      display: block;
+      padding: 20px 10px;
+    }
+  }
+}
+
+ul.group-list-tree {
+  li.group-row {
+    &.has-description {
+      .title {
+        line-height: inherit;
+      }
+    }
+
+    .title {
+      line-height: $list-text-height;
+    }
   }
 }
 
diff --git a/app/assets/stylesheets/framework/new-sidebar.scss b/app/assets/stylesheets/framework/new-sidebar.scss
index 78972717932faf90610737c9fa802bdc1479aa6d..17fa31c450d35d6f6ffddcd64825862a7b3078ca 100644
--- a/app/assets/stylesheets/framework/new-sidebar.scss
+++ b/app/assets/stylesheets/framework/new-sidebar.scss
@@ -466,7 +466,7 @@ $new-sidebar-collapsed-width: 50px;
 
   @media (max-width: $screen-xs-max) {
     + .breadcrumbs-links {
-      padding-left: 17px;
+      padding-left: $gl-padding;
       border-left: 1px solid $gl-text-color-quaternary;
     }
   }
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 089a67a7c989e06f0f2183b700fa63728843cdee..d5ca23ff8702061ee178afefe4576089565ec7cb 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -233,6 +233,7 @@ $container-text-max-width: 540px;
 $gl-avatar-size: 40px;
 $error-exclamation-point: $red-500;
 $border-radius-default: 4px;
+$border-radius-small: 2px;
 $settings-icon-size: 18px;
 $provider-btn-not-active-color: $blue-500;
 $link-underline-blue: $blue-500;
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 5538e46a6c4c2869b2c870c2e402e9d19a21fae0..8d6f30e3b845f37ebf36fd9269fe6b4777b1cf6d 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -4,6 +4,6 @@
   }
 
   .alert-block {
-    margin-bottom: 20px;
+    margin-bottom: 10px;
   }
 }
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index d3cd4d507de1315174f085500c5590be18015529..edfafa79c44569ceec3f2b30e66d495f7edee94b 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -4,7 +4,7 @@
     border-right: 1px solid $border-color;
     border-left: 1px solid $border-color;
     border-bottom: none;
-    border-radius: 2px;
+    border-radius: $border-radius-small $border-radius-small 0 0;
     background: $gray-normal;
   }
 
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 6f6c683997581c32c568743d28ea6995f066eee8..9b7dda9b648e6c49560a28c7588c589f0f8a884d 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -26,14 +26,117 @@
   }
 }
 
-.groups-header {
-  @media (min-width: $screen-sm-min) {
-    .nav-links {
-      width: 35%;
+.group-nav-container .nav-controls {
+  display: flex;
+  align-items: flex-start;
+  padding: $gl-padding-top 0;
+  border-bottom: 1px solid $border-color;
+
+  .group-filter-form {
+    flex: 1;
+  }
+
+  .dropdown-menu-align-right {
+    margin-top: 0;
+  }
+
+  .new-project-subgroup {
+    .dropdown-primary {
+      min-width: 115px;
+    }
+
+    .dropdown-toggle {
+      .dropdown-btn-icon {
+        pointer-events: none;
+        color: inherit;
+        margin-left: 0;
+      }
     }
 
-    .nav-controls {
-      width: 65%;
+    .dropdown-menu {
+      min-width: 280px;
+      margin-top: 2px;
+    }
+
+    li:not(.divider) {
+      padding: 0;
+
+      &.droplab-item-selected {
+        .icon-container {
+          .list-item-checkmark {
+            visibility: visible;
+          }
+        }
+      }
+
+      .menu-item {
+        padding: 8px 4px;
+
+        &:hover {
+          background-color: $gray-darker;
+          color: $theme-gray-900;
+        }
+      }
+
+      .icon-container {
+        float: left;
+        padding-left: 6px;
+
+        .list-item-checkmark {
+          visibility: hidden;
+        }
+      }
+
+      .description {
+        font-size: 14px;
+
+        strong {
+          display: block;
+          font-weight: $gl-font-weight-bold;
+        }
+      }
+    }
+  }
+
+  @media (max-width: $screen-sm-max) {
+    &,
+    .dropdown,
+    .dropdown .dropdown-toggle,
+    .btn-new {
+      display: block;
+    }
+
+    .group-filter-form,
+    .dropdown {
+      margin-bottom: 10px;
+      margin-right: 0;
+    }
+
+    .group-filter-form,
+    .dropdown .dropdown-toggle,
+    .btn-new {
+      width: 100%;
+    }
+
+    .dropdown .dropdown-toggle .fa-chevron-down {
+      position: absolute;
+      top: 11px;
+      right: 8px;
+    }
+
+    .new-project-subgroup {
+      display: flex;
+      align-items: flex-start;
+
+      .dropdown-primary {
+        flex: 1;
+      }
+
+      .dropdown-menu {
+        width: 100%;
+        max-width: inherit;
+        min-width: inherit;
+      }
     }
   }
 }
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index c93c4e93af5c33fbaae391c12a20790d76bd9edd..485325032639391c2bfdc9a1b067facc288bcc51 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -72,12 +72,22 @@
     }
   }
 
+  .title-container {
+    display: flex;
+  }
+
   .title {
     padding: 0;
     margin-bottom: 16px;
     border-bottom: none;
   }
 
+  .btn-edit {
+    margin-left: auto;
+    // Set height to match title height
+    height: 2em;
+  }
+
   // Border around images in issue and MR descriptions.
   .description img:not(.emoji) {
     border: 1px solid $white-normal;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 96b7db3b85dcb09d8d252bcba61b5c6fb5518b03..ebad429c2baa48cff45cc7726d4e8c3ca115c103 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -531,14 +531,13 @@ ul.notes {
   padding: 0;
   min-width: 16px;
   color: $gray-darkest;
+  fill: $gray-darkest;
 
   .fa {
     position: relative;
     font-size: 16px;
   }
 
-
-
   svg {
     height: 16px;
     width: 16px;
@@ -566,6 +565,7 @@ ul.notes {
 
     .link-highlight {
       color: $gl-link-color;
+      fill: $gl-link-color;
 
       svg {
         fill: $gl-link-color;
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index c36fe25f74d73edb2aa14c2e5e950557d3ce3573..ea37ccf5e3d4b728cb33d6fec9ac38270968b94d 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -153,28 +153,13 @@
       overflow-x: auto;
 
       li {
-        animation: swipeRightAppear ease-in 0.1s;
-        animation-iteration-count: 1;
-        transform-origin: 0% 50%;
-        list-style-type: none;
+        position: relative;
         background: $gray-normal;
-        display: inline-block;
         padding: #{$gl-padding / 2} $gl-padding;
         border-right: 1px solid $white-dark;
         border-bottom: 1px solid $white-dark;
-        white-space: nowrap;
         cursor: pointer;
 
-        &.remove {
-          animation: swipeRightDissapear ease-in 0.1s;
-          animation-iteration-count: 1;
-          transform-origin: 0% 50%;
-
-          a {
-            width: 0;
-          }
-        }
-
         &.active {
           background: $white-light;
           border-bottom: none;
@@ -182,17 +167,21 @@
 
         a {
           @include str-truncated(100px);
-          color: $black;
+          color: $gl-text-color;
           vertical-align: middle;
           text-decoration: none;
           margin-right: 12px;
+        }
 
-          &.close {
-            width: auto;
-            font-size: 15px;
-            opacity: 1;
-            margin-right: -6px;
-          }
+        .close-btn {
+          position: absolute;
+          right: 8px;
+          top: 50%;
+          padding: 0;
+          background: none;
+          border: 0;
+          font-size: $gl-font-size;
+          transform: translateY(-50%);
         }
 
         .close-icon:hover {
@@ -201,9 +190,6 @@
 
         .close-icon,
         .unsaved-icon {
-          float: right;
-          margin-top: 3px;
-          margin-left: 15px;
           color: $gray-darkest;
         }
 
@@ -222,9 +208,7 @@
 
     #repo-file-buttons {
       background-color: $white-light;
-      border-bottom: 1px solid $white-normal;
       padding: 5px 10px;
-      position: relative;
       border-top: 1px solid $white-normal;
     }
 
@@ -287,37 +271,23 @@
       overflow: auto;
     }
 
-    table {
+    .table {
       margin-bottom: 0;
     }
 
     tr {
-      animation: fadein 0.5s;
-      cursor: pointer;
-
-      &.repo-file-options td {
-        padding: 0;
-        border-top: none;
-        background: $gray-light;
+      .repo-file-options {
+        padding: 2px 16px;
         width: 100%;
-        display: inline-block;
-
-        &:first-child {
-          border-top-left-radius: 2px;
-        }
+      }
 
-        .title {
-          display: inline-block;
-          font-size: 10px;
-          text-transform: uppercase;
-          font-weight: $gl-font-weight-bold;
-          color: $gray-darkest;
-          white-space: nowrap;
-          overflow: hidden;
-          text-overflow: ellipsis;
-          vertical-align: middle;
-          padding: 2px 16px;
-        }
+      .title {
+        font-size: 10px;
+        text-transform: uppercase;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        vertical-align: middle;
       }
 
       .file-icon {
@@ -329,11 +299,13 @@
       }
     }
 
+    .file {
+      cursor: pointer;
+    }
+
     a {
       @include str-truncated(250px);
       color: $almost-black;
-      display: inline-block;
-      vertical-align: middle;
     }
   }
 }
diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9d4f97aa443f2528e4411faa6eacfc1857c72c9f
--- /dev/null
+++ b/app/controllers/concerns/group_tree.rb
@@ -0,0 +1,24 @@
+module GroupTree
+  def render_group_tree(groups)
+    @groups = if params[:filter].present?
+                Gitlab::GroupHierarchy.new(groups.search(params[:filter]))
+                  .base_and_ancestors
+              else
+                # Only show root groups if no parent-id is given
+                groups.where(parent_id: params[:parent_id])
+              end
+    @groups = @groups.with_selects_for_list(archived: params[:archived])
+                .sort(@sort = params[:sort])
+                .page(params[:page])
+
+    respond_to do |format|
+      format.html
+      format.json do
+        serializer = GroupChildSerializer.new(current_user: current_user)
+                       .with_pagination(request, response)
+        serializer.expand_hierarchy if params[:filter].present?
+        render json: serializer.represent(@groups)
+      end
+    end
+  end
+end
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index 7ed18fb481cf9cb938edadf1173668a3c81e6d0d..025769f512a80ed798359910721f7bcd9a465366 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -1,33 +1,8 @@
 class Dashboard::GroupsController < Dashboard::ApplicationController
-  def index
-    @sort = params[:sort] || 'created_desc'
-
-    @groups =
-      if params[:parent_id] && Group.supports_nested_groups?
-        parent = Group.find_by(id: params[:parent_id])
-
-        if can?(current_user, :read_group, parent)
-          GroupsFinder.new(current_user, parent: parent).execute
-        else
-          Group.none
-        end
-      else
-        current_user.groups
-      end
+  include GroupTree
 
-    @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
-    @groups = @groups.includes(:route)
-    @groups = @groups.sort(@sort)
-    @groups = @groups.page(params[:page])
-
-    respond_to do |format|
-      format.html
-      format.json do
-        render json: GroupSerializer
-          .new(current_user: @current_user)
-          .with_pagination(request, response)
-          .represent(@groups)
-      end
-    end
+  def index
+    groups = GroupsFinder.new(current_user, all_available: false).execute
+    render_group_tree(groups)
   end
 end
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index 81883c543ba81cf4724cfaca346aec40619cae99..fa0a0f68fbc45e2dd8b96e6600fcb2d2ed35d57e 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -1,17 +1,7 @@
 class Explore::GroupsController < Explore::ApplicationController
-  def index
-    @groups = GroupsFinder.new(current_user).execute
-    @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
-    @groups = @groups.sort(@sort = params[:sort])
-    @groups = @groups.page(params[:page])
+  include GroupTree
 
-    respond_to do |format|
-      format.html
-      format.json do
-        render json: {
-          html: view_to_html_string("explore/groups/_groups", locals: { groups: @groups })
-        }
-      end
-    end
+  def index
+    render_group_tree GroupsFinder.new(current_user).execute
   end
 end
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b474f5d15eef041eb8b09291c0126c78bbe10b78
--- /dev/null
+++ b/app/controllers/groups/children_controller.rb
@@ -0,0 +1,39 @@
+module Groups
+  class ChildrenController < Groups::ApplicationController
+    before_action :group
+
+    def index
+      parent = if params[:parent_id].present?
+                 GroupFinder.new(current_user).execute(id: params[:parent_id])
+               else
+                 @group
+               end
+
+      if parent.nil?
+        render_404
+        return
+      end
+
+      setup_children(parent)
+
+      respond_to do |format|
+        format.json do
+          serializer = GroupChildSerializer
+                         .new(current_user: current_user)
+                         .with_pagination(request, response)
+          serializer.expand_hierarchy(parent) if params[:filter].present?
+          render json: serializer.represent(@children)
+        end
+      end
+    end
+
+    protected
+
+    def setup_children(parent)
+      @children = GroupDescendantsFinder.new(current_user: current_user,
+                                             parent_group: parent,
+                                             params: params).execute
+      @children = @children.page(params[:page])
+    end
+  end
+end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index a962d82e3b51bb9d15fe40f3d9f0cf45933f4b50..e23a82d01be099c9cfdf6333a9926e3b63e34b46 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -46,15 +46,11 @@ class GroupsController < Groups::ApplicationController
   end
 
   def show
-    setup_projects
-
     respond_to do |format|
-      format.html
-
-      format.json do
-        render json: {
-          html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
-        }
+      format.html do
+        @has_children = GroupDescendantsFinder.new(current_user: current_user,
+                                                   parent_group: @group,
+                                                   params: params).has_children?
       end
 
       format.atom do
@@ -64,13 +60,6 @@ class GroupsController < Groups::ApplicationController
     end
   end
 
-  def subgroups
-    return not_found unless Group.supports_nested_groups?
-
-    @nested_groups = GroupsFinder.new(current_user, parent: group).execute
-    @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present?
-  end
-
   def activity
     respond_to do |format|
       format.html
@@ -107,20 +96,6 @@ class GroupsController < Groups::ApplicationController
 
   protected
 
-  def setup_projects
-    set_non_archived_param
-    params[:sort] ||= 'latest_activity_desc'
-    @sort = params[:sort]
-
-    options = {}
-    options[:only_owned] = true if params[:shared] == '0'
-    options[:only_shared] = true if params[:shared] == '1'
-
-    @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user).execute
-    @projects = @projects.includes(:namespace)
-    @projects = @projects.page(params[:page]) if params[:name].blank?
-  end
-
   def authorize_create_group!
     allowed = if params[:parent_id].present?
                 parent = Group.find_by(id: params[:parent_id])
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index f3719059f883f8dcd58b60824591ca43205a0af7..756f7e5df8c35dfdea3872d6cc39ee06100b7d5d 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -36,6 +36,7 @@ class Projects::TreeController < Projects::ApplicationController
 
       format.json do
         page_title @path.presence || _("Files"), @ref, @project.name_with_namespace
+        response.header['is-root'] = @path.empty?
 
         # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261
         Gitlab::GitalyClient.allow_n_plus_1_calls do
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 592706f4e0e11bdb7524d2bd4a1ff02cbcb14bb0..db543d688a026e54d501a7b4ca185c594c1b61b6 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -126,7 +126,7 @@ class ProjectsController < Projects::ApplicationController
     return access_denied! unless can?(current_user, :remove_project, @project)
 
     ::Projects::DestroyService.new(@project, current_user, {}).async_execute
-    flash[:alert] = _("Project '%{project_name}' will be deleted.") % { project_name: @project.name_with_namespace }
+    flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.name_with_namespace }
 
     redirect_to dashboard_projects_path, status: 302
   rescue Projects::DestroyService::DestroyError => ex
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1a5f606343724f060a03dcaba9ca148c287e14ff
--- /dev/null
+++ b/app/finders/group_descendants_finder.rb
@@ -0,0 +1,153 @@
+# GroupDescendantsFinder
+#
+# Used to find and filter all subgroups and projects of a passed parent group
+# visible to a specified user.
+#
+# When passing a `filter` param, the search is performed over all nested levels
+# of the `parent_group`. All ancestors for a search result are loaded
+#
+# Arguments:
+#   current_user: The user for which the children should be visible
+#   parent_group: The group to find children of
+#   params:
+#     Supports all params that the `ProjectsFinder` and `GroupProjectsFinder`
+#     support.
+#
+#     filter: string - is aliased to `search` for consistency with the frontend
+#     archived: string - `only` or `true`.
+#                        `non_archived` is passed to the `ProjectFinder`s if none
+#                        was given.
+class GroupDescendantsFinder
+  attr_reader :current_user, :parent_group, :params
+
+  def initialize(current_user: nil, parent_group:, params: {})
+    @current_user = current_user
+    @parent_group = parent_group
+    @params = params.reverse_merge(non_archived: params[:archived].blank?)
+  end
+
+  def execute
+    # The children array might be extended with the ancestors of projects when
+    # filtering. In that case, take the maximum so the array does not get limited
+    # Otherwise, allow paginating through all results
+    #
+    all_required_elements = children
+    all_required_elements |= ancestors_for_projects if params[:filter]
+    total_count = [all_required_elements.size, paginator.total_count].max
+
+    Kaminari.paginate_array(all_required_elements, total_count: total_count)
+  end
+
+  def has_children?
+    projects.any? || subgroups.any?
+  end
+
+  private
+
+  def children
+    @children ||= paginator.paginate(params[:page])
+  end
+
+  def paginator
+    @paginator ||= Gitlab::MultiCollectionPaginator.new(subgroups, projects,
+                                                        per_page: params[:per_page])
+  end
+
+  def direct_child_groups
+    GroupsFinder.new(current_user,
+                     parent: parent_group,
+                     all_available: true).execute
+  end
+
+  def all_visible_descendant_groups
+    groups_table = Group.arel_table
+    visible_to_user = groups_table[:visibility_level]
+                      .in(Gitlab::VisibilityLevel.levels_for_user(current_user))
+    if current_user
+      authorized_groups = GroupsFinder.new(current_user,
+                                           all_available: false)
+                            .execute.as('authorized')
+      authorized_to_user = groups_table.project(1).from(authorized_groups)
+                             .where(authorized_groups[:id].eq(groups_table[:id]))
+                             .exists
+      visible_to_user = visible_to_user.or(authorized_to_user)
+    end
+
+    hierarchy_for_parent
+      .descendants
+      .where(visible_to_user)
+  end
+
+  def subgroups_matching_filter
+    all_visible_descendant_groups
+      .search(params[:filter])
+  end
+
+  # When filtering we want all to preload all the ancestors upto the specified
+  # parent group.
+  #
+  # - root
+  #   - subgroup
+  #     - nested-group
+  #       - project
+  #
+  # So when searching 'project', on the 'subgroup' page we want to preload
+  # 'nested-group' but not 'subgroup' or 'root'
+  def ancestors_for_groups(base_for_ancestors)
+    Gitlab::GroupHierarchy.new(base_for_ancestors)
+      .base_and_ancestors(upto: parent_group.id)
+  end
+
+  def ancestors_for_projects
+    projects_to_load_ancestors_of = projects.where.not(namespace: parent_group)
+    groups_to_load_ancestors_of = Group.where(id: projects_to_load_ancestors_of.select(:namespace_id))
+    ancestors_for_groups(groups_to_load_ancestors_of)
+      .with_selects_for_list(archived: params[:archived])
+  end
+
+  def subgroups
+    return Group.none unless Group.supports_nested_groups?
+
+    # When filtering subgroups, we want to find all matches withing the tree of
+    # descendants to show to the user
+    groups = if params[:filter]
+               ancestors_for_groups(subgroups_matching_filter)
+             else
+               direct_child_groups
+             end
+    groups.with_selects_for_list(archived: params[:archived]).order_by(sort)
+  end
+
+  def direct_child_projects
+    GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
+      .execute
+  end
+
+  # Finds all projects nested under `parent_group` or any of its descendant
+  # groups
+  def projects_matching_filter
+    projects_nested_in_group = Project.where(namespace_id: hierarchy_for_parent.base_and_descendants.select(:id))
+    params_with_search = params.merge(search: params[:filter])
+
+    ProjectsFinder.new(params: params_with_search,
+                       current_user: current_user,
+                       project_ids_relation: projects_nested_in_group).execute
+  end
+
+  def projects
+    projects = if params[:filter]
+                 projects_matching_filter
+               else
+                 direct_child_projects
+               end
+    projects.with_route.order_by(sort)
+  end
+
+  def sort
+    params.fetch(:sort, 'id_asc')
+  end
+
+  def hierarchy_for_parent
+    @hierarchy ||= Gitlab::GroupHierarchy.new(Group.where(id: parent_group.id))
+  end
+end
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index f2d3b90b8e260388e75bdae1bfdf1f2d2599b3cb..6e8733bb49cc097d7fec96e0e4b95b028a6f4e6d 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -34,7 +34,6 @@ class GroupProjectsFinder < ProjectsFinder
                else
                  collection_without_user
                end
-
     union(projects)
   end
 
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 7bd34df5c954667e85333a04f4300f269ba59c3f..1ee8911bb1a49814db8fbe49ae33977f2686800c 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -108,6 +108,34 @@ module ApplicationSettingsHelper
     options_for_select(Sidekiq::Queue.all.map(&:name), @application_setting.sidekiq_throttling_queues)
   end
 
+  def circuitbreaker_failure_count_help_text
+    health_link = link_to(s_('AdminHealthPageLink|health page'), admin_health_check_path)
+    api_link = link_to(s_('CircuitBreakerApiLink|circuitbreaker api'), help_page_path("api/repository_storage_health"))
+    message = _("The number of failures of after which GitLab will completely "\
+                "prevent access to the storage. The number of failures can be "\
+                "reset in the admin interface: %{link_to_health_page} or using "\
+                "the %{api_documentation_link}.")
+    message = message % { link_to_health_page: health_link, api_documentation_link: api_link }
+
+    message.html_safe
+  end
+
+  def circuitbreaker_failure_wait_time_help_text
+    _("When access to a storage fails. GitLab will prevent access to the "\
+      "storage for the time specified here. This allows the filesystem to "\
+      "recover. Repositories on failing shards are temporarly unavailable")
+  end
+
+  def circuitbreaker_failure_reset_time_help_text
+    _("The time in seconds GitLab will keep failure information. When no "\
+      "failures occur during this time, information about the mount is reset.")
+  end
+
+  def circuitbreaker_storage_timeout_help_text
+    _("The time in seconds GitLab will try to access storage. After this time a "\
+      "timeout error will be raised.")
+  end
+
   def visible_attributes
     [
       :admin_notification_email,
@@ -116,6 +144,10 @@ module ApplicationSettingsHelper
       :akismet_api_key,
       :akismet_enabled,
       :auto_devops_enabled,
+      :circuitbreaker_failure_count_threshold,
+      :circuitbreaker_failure_reset_time,
+      :circuitbreaker_failure_wait_time,
+      :circuitbreaker_storage_timeout,
       :clientside_sentry_dsn,
       :clientside_sentry_enabled,
       :container_registry_token_expire_delay,
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 1b542ed2a96d87dc6840fcab70d7efbbaf78b39e..b05eb93b465637301d7e8611d26d89005ad676bf 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -42,6 +42,17 @@ module SortingHelper
     options
   end
 
+  def groups_sort_options_hash
+    options = {
+      sort_value_recently_created => sort_title_recently_created,
+      sort_value_oldest_created => sort_title_oldest_created,
+      sort_value_recently_updated => sort_title_recently_updated,
+      sort_value_oldest_updated => sort_title_oldest_updated
+    }
+
+    options
+  end
+
   def member_sort_options_hash
     {
       sort_value_access_level_asc  => sort_title_access_level_asc,
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index c0cc60d5ebf3be68a3a0591946e4940eee261704..d3b8debb0fd1bfc3022923b8df305f4e6c32111d 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -33,6 +33,8 @@ class ApplicationSetting < ActiveRecord::Base
 
   attr_accessor :domain_whitelist_raw, :domain_blacklist_raw
 
+  default_value_for :id, 1
+
   validates :uuid, presence: true
 
   validates :session_expire_delay,
@@ -151,6 +153,13 @@ class ApplicationSetting < ActiveRecord::Base
             presence: true,
             numericality: { greater_than_or_equal_to: 0 }
 
+  validates :circuitbreaker_failure_count_threshold,
+            :circuitbreaker_failure_wait_time,
+            :circuitbreaker_failure_reset_time,
+            :circuitbreaker_storage_timeout,
+            presence: true,
+            numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
   SUPPORTED_KEY_TYPES.each do |type|
     validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
   end
diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb
new file mode 100644
index 0000000000000000000000000000000000000000..01957da0bf3e61ad0ceb1d51023e21c4851bf72d
--- /dev/null
+++ b/app/models/concerns/group_descendant.rb
@@ -0,0 +1,56 @@
+module GroupDescendant
+  # Returns the hierarchy of a project or group in the from of a hash upto a
+  # given top.
+  #
+  # > project.hierarchy
+  # => { parent_group => { child_group => project } }
+  def hierarchy(hierarchy_top = nil, preloaded = nil)
+    preloaded ||= ancestors_upto(hierarchy_top)
+    expand_hierarchy_for_child(self, self, hierarchy_top, preloaded)
+  end
+
+  # Merges all hierarchies of the given groups or projects into an array of
+  # hashes. All ancestors need to be loaded into the given `descendants` to avoid
+  # queries down the line.
+  #
+  # > GroupDescendant.merge_hierarchy([project, child_group, child_group2, parent])
+  # => { parent => [{ child_group => project}, child_group2] }
+  def self.build_hierarchy(descendants, hierarchy_top = nil)
+    descendants = Array.wrap(descendants).uniq
+    return [] if descendants.empty?
+
+    unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) }
+      raise ArgumentError.new('element is not a hierarchy')
+    end
+
+    all_hierarchies = descendants.map do |descendant|
+      descendant.hierarchy(hierarchy_top, descendants)
+    end
+
+    Gitlab::Utils::MergeHash.merge(all_hierarchies)
+  end
+
+  private
+
+  def expand_hierarchy_for_child(child, hierarchy, hierarchy_top, preloaded)
+    parent = hierarchy_top if hierarchy_top && child.parent_id == hierarchy_top.id
+    parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id }
+
+    if parent.nil? && !child.parent_id.nil?
+      raise ArgumentError.new('parent was not preloaded')
+    end
+
+    if parent.nil? && hierarchy_top.present?
+      raise ArgumentError.new('specified top is not part of the tree')
+    end
+
+    if parent && parent != hierarchy_top
+      expand_hierarchy_for_child(parent,
+                                 { parent => hierarchy },
+                                 hierarchy_top,
+                                 preloaded)
+    else
+      hierarchy
+    end
+  end
+end
diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb
new file mode 100644
index 0000000000000000000000000000000000000000..dcb3b2b5ff3977f0377c40e9e59950f26619da4b
--- /dev/null
+++ b/app/models/concerns/loaded_in_group_list.rb
@@ -0,0 +1,72 @@
+module LoadedInGroupList
+  extend ActiveSupport::Concern
+
+  module ClassMethods
+    def with_counts(archived:)
+      selects_including_counts = [
+        'namespaces.*',
+        "(#{project_count_sql(archived).to_sql}) AS preloaded_project_count",
+        "(#{member_count_sql.to_sql}) AS preloaded_member_count",
+        "(#{subgroup_count_sql.to_sql}) AS preloaded_subgroup_count"
+      ]
+
+      select(selects_including_counts)
+    end
+
+    def with_selects_for_list(archived: nil)
+      with_route.with_counts(archived: archived)
+    end
+
+    private
+
+    def project_count_sql(archived = nil)
+      projects = Project.arel_table
+      namespaces = Namespace.arel_table
+
+      base_count = projects.project(Arel.star.count.as('preloaded_project_count'))
+                     .where(projects[:namespace_id].eq(namespaces[:id]))
+      if archived == 'only'
+        base_count.where(projects[:archived].eq(true))
+      elsif Gitlab::Utils.to_boolean(archived)
+        base_count
+      else
+        base_count.where(projects[:archived].not_eq(true))
+      end
+    end
+
+    def subgroup_count_sql
+      namespaces = Namespace.arel_table
+      children = namespaces.alias('children')
+
+      namespaces.project(Arel.star.count.as('preloaded_subgroup_count'))
+        .from(children)
+        .where(children[:parent_id].eq(namespaces[:id]))
+    end
+
+    def member_count_sql
+      members = Member.arel_table
+      namespaces = Namespace.arel_table
+
+      members.project(Arel.star.count.as('preloaded_member_count'))
+        .where(members[:source_type].eq(Namespace.name))
+        .where(members[:source_id].eq(namespaces[:id]))
+        .where(members[:requested_at].eq(nil))
+    end
+  end
+
+  def children_count
+    @children_count ||= project_count + subgroup_count
+  end
+
+  def project_count
+    @project_count ||= try(:preloaded_project_count) || projects.non_archived.count
+  end
+
+  def subgroup_count
+    @subgroup_count ||= try(:preloaded_subgroup_count) || children.count
+  end
+
+  def member_count
+    @member_count ||= try(:preloaded_member_count) || users.count
+  end
+end
diff --git a/app/models/group.rb b/app/models/group.rb
index e746e4a12c9e3ea981b157fd423312df49719505..07fb62bb2495e5a6b3c077c8a05fde15ef643a5f 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -6,6 +6,8 @@ class Group < Namespace
   include Avatarable
   include Referable
   include SelectForProjectAuthorization
+  include LoadedInGroupList
+  include GroupDescendant
 
   has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
   alias_method :members, :group_members
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 4672881e2208bbf0c1034c31201a33cb0ee09606..0601a61a926542f7227063978cd13faf84e3a558 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base
       .base_and_ancestors
   end
 
+  # returns all ancestors upto but excluding the the given namespace
+  # when no namespace is given, all ancestors upto the top are returned
+  def ancestors_upto(top = nil)
+    Gitlab::GroupHierarchy.new(self.class.where(id: id))
+      .ancestors(upto: top)
+  end
+
   def self_and_ancestors
     return self.class.where(id: id) unless parent_id
 
diff --git a/app/models/project.rb b/app/models/project.rb
index 57e91ab3b8823f10de61cf8e1ab52665fbf901fd..4689b5889060b8f426ee803e7c073617e0ce2c36 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -17,6 +17,7 @@ class Project < ActiveRecord::Base
   include ProjectFeaturesCompatibility
   include SelectForProjectAuthorization
   include Routable
+  include GroupDescendant
 
   extend Gitlab::ConfigHelper
   extend Gitlab::CurrentSettings
@@ -81,6 +82,8 @@ class Project < ActiveRecord::Base
   belongs_to :creator, class_name: 'User'
   belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
   belongs_to :namespace
+  alias_method :parent, :namespace
+  alias_attribute :parent_id, :namespace_id
 
   has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
   has_many :boards, before_add: :validate_board_limit
@@ -479,6 +482,13 @@ class Project < ActiveRecord::Base
     end
   end
 
+  # returns all ancestor-groups upto but excluding the given namespace
+  # when no namespace is given, all ancestors upto the top are returned
+  def ancestors_upto(top = nil)
+    Gitlab::GroupHierarchy.new(Group.where(id: namespace_id))
+      .base_and_ancestors(upto: top)
+  end
+
   def lfs_enabled?
     return namespace.lfs_enabled? if self[:lfs_enabled].nil?
 
@@ -1262,7 +1272,7 @@ class Project < ActiveRecord::Base
 
     # self.forked_from_project will be nil before the project is saved, so
     # we need to go through the relation
-    original_project = forked_project_link.forked_from_project
+    original_project = forked_project_link&.forked_from_project
     return true unless original_project
 
     level <= original_project.visibility_level
@@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base
     map.public_path_for_source_path(path)
   end
 
-  def parent
-    namespace
-  end
-
   def parent_changed?
     namespace_id_changed?
   end
diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb
index 4e6c15f673b00e7907adf24d1b657725303b236b..8cade280b0c633d712723661bf6e6f7711208ca3 100644
--- a/app/serializers/base_serializer.rb
+++ b/app/serializers/base_serializer.rb
@@ -1,6 +1,9 @@
 class BaseSerializer
-  def initialize(parameters = {})
-    @request = EntityRequest.new(parameters)
+  attr_reader :params
+
+  def initialize(params = {})
+    @params = params
+    @request = EntityRequest.new(params)
   end
 
   def represent(resource, opts = {}, entity_class = nil)
diff --git a/app/serializers/concerns/with_pagination.rb b/app/serializers/concerns/with_pagination.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d29e22d67409d2197158cf0bdcb2c9d8aef2fd6a
--- /dev/null
+++ b/app/serializers/concerns/with_pagination.rb
@@ -0,0 +1,22 @@
+module WithPagination
+  attr_accessor :paginator
+
+  def with_pagination(request, response)
+    tap { self.paginator = Gitlab::Serializer::Pagination.new(request, response) }
+  end
+
+  def paginated?
+    paginator.present?
+  end
+
+  # super is `BaseSerializer#represent` here.
+  #
+  # we shouldn't try to paginate single resources
+  def represent(resource, opts = {})
+    if paginated? && resource.respond_to?(:page)
+      super(@paginator.paginate(resource), opts)
+    else
+      super(resource, opts)
+    end
+  end
+end
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 88842a9aa7563bf11071e068579dec945b07efe9..84722f33f59d6c400735ca356dece4e3fce3e42d 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -1,4 +1,6 @@
 class EnvironmentSerializer < BaseSerializer
+  include WithPagination
+
   Item = Struct.new(:name, :size, :latest)
 
   entity EnvironmentEntity
@@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer
     tap { @itemize = true }
   end
 
-  def with_pagination(request, response)
-    tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
-  end
-
   def itemized?
     @itemize
   end
 
-  def paginated?
-    @paginator.present?
-  end
-
   def represent(resource, opts = {})
     if itemized?
       itemize(resource).map do |item|
@@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer
           latest: super(item.latest, opts) }
       end
     else
-      resource = @paginator.paginate(resource) if paginated?
-
       super(resource, opts)
     end
   end
diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb
new file mode 100644
index 0000000000000000000000000000000000000000..37240bfb0b164421f11fcb86321720c82cafb0d1
--- /dev/null
+++ b/app/serializers/group_child_entity.rb
@@ -0,0 +1,77 @@
+class GroupChildEntity < Grape::Entity
+  include ActionView::Helpers::NumberHelper
+  include RequestAwareEntity
+
+  expose :id, :name, :description, :visibility, :full_name,
+         :created_at, :updated_at, :avatar_url
+
+  expose :type do |instance|
+    type
+  end
+
+  expose :can_edit do |instance|
+    return false unless request.respond_to?(:current_user)
+
+    can?(request.current_user, "admin_#{type}", instance)
+  end
+
+  expose :edit_path do |instance|
+    # We know `type` will be one either `project` or `group`.
+    # The `edit_polymorphic_path` helper would try to call the path helper
+    # with a plural: `edit_groups_path(instance)` or `edit_projects_path(instance)`
+    # while our methods are `edit_group_path` or `edit_group_path`
+    public_send("edit_#{type}_path", instance) # rubocop:disable GitlabSecurity/PublicSend
+  end
+
+  expose :relative_path do |instance|
+    polymorphic_path(instance)
+  end
+
+  expose :permission do |instance|
+    membership&.human_access
+  end
+
+  # Project only attributes
+  expose :star_count,
+         if: lambda { |_instance, _options| project? }
+
+  # Group only attributes
+  expose :children_count, :parent_id, :project_count, :subgroup_count,
+         unless: lambda { |_instance, _options| project? }
+
+  expose :leave_path, unless: lambda { |_instance, _options| project? } do |instance|
+    leave_group_members_path(instance)
+  end
+
+  expose :can_leave, unless: lambda { |_instance, _options| project? } do |instance|
+    if membership
+      can?(request.current_user, :destroy_group_member, membership)
+    else
+      false
+    end
+  end
+
+  expose :number_projects_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
+    number_with_delimiter(instance.project_count)
+  end
+
+  expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
+    number_with_delimiter(instance.member_count)
+  end
+
+  private
+
+  def membership
+    return unless request.current_user
+
+    @membership ||= request.current_user.members.find_by(source: object)
+  end
+
+  def project?
+    object.is_a?(Project)
+  end
+
+  def type
+    object.class.name.downcase
+  end
+end
diff --git a/app/serializers/group_child_serializer.rb b/app/serializers/group_child_serializer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2baef0a57034a689d1f7f57d37e2674372f82bd3
--- /dev/null
+++ b/app/serializers/group_child_serializer.rb
@@ -0,0 +1,51 @@
+class GroupChildSerializer < BaseSerializer
+  include WithPagination
+
+  attr_reader :hierarchy_root, :should_expand_hierarchy
+
+  entity GroupChildEntity
+
+  def expand_hierarchy(hierarchy_root = nil)
+    @hierarchy_root = hierarchy_root
+    @should_expand_hierarchy = true
+
+    self
+  end
+
+  def represent(resource, opts = {}, entity_class = nil)
+    if should_expand_hierarchy
+      paginator.paginate(resource) if paginated?
+      represent_hierarchies(resource, opts)
+    else
+      super(resource, opts)
+    end
+  end
+
+  protected
+
+  def represent_hierarchies(children, opts)
+    if children.is_a?(GroupDescendant)
+      represent_hierarchy(children.hierarchy(hierarchy_root), opts).first
+    else
+      hierarchies = GroupDescendant.build_hierarchy(children, hierarchy_root)
+      # When an array was passed, we always want to represent an array.
+      # Even if the hierarchy only contains one element
+      represent_hierarchy(Array.wrap(hierarchies), opts)
+    end
+  end
+
+  def represent_hierarchy(hierarchy, opts)
+    serializer = self.class.new(params)
+
+    if hierarchy.is_a?(Hash)
+      hierarchy.map do |parent, children|
+        serializer.represent(parent, opts)
+          .merge(children: Array.wrap(serializer.represent_hierarchy(children, opts)))
+      end
+    elsif hierarchy.is_a?(Array)
+      hierarchy.flat_map { |child| serializer.represent_hierarchy(child, opts) }
+    else
+      serializer.represent(hierarchy, opts)
+    end
+  end
+end
diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb
index 26e8566828b118e22094df52514855e44c72364d..8cf7eb63bcf0123194b16a00b6d5f4e87fbe075c 100644
--- a/app/serializers/group_serializer.rb
+++ b/app/serializers/group_serializer.rb
@@ -1,19 +1,5 @@
 class GroupSerializer < BaseSerializer
-  entity GroupEntity
-
-  def with_pagination(request, response)
-    tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
-  end
+  include WithPagination
 
-  def paginated?
-    @paginator.present?
-  end
-
-  def represent(resource, opts = {})
-    if paginated?
-      super(@paginator.paginate(resource), opts)
-    else
-      super(resource, opts)
-    end
-  end
+  entity GroupEntity
 end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 661bf17983cb5b099d5a43b6058e1e9d00c0971b..7181f8a6b04b60997d936301e88be35b2b628671 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -1,16 +1,10 @@
 class PipelineSerializer < BaseSerializer
+  include WithPagination
+
   InvalidResourceError = Class.new(StandardError)
 
   entity PipelineDetailsEntity
 
-  def with_pagination(request, response)
-    tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
-  end
-
-  def paginated?
-    @paginator.present?
-  end
-
   def represent(resource, opts = {})
     if resource.is_a?(ActiveRecord::Relation)
 
diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb
index abe414d0c050773880bf741f8ee74377026dca7a..2b82e5732e4b65e072f06669d26250df7c980328 100644
--- a/app/services/projects/unlink_fork_service.rb
+++ b/app/services/projects/unlink_fork_service.rb
@@ -15,8 +15,8 @@ module Projects
 
       refresh_forks_count(@project.forked_from_project)
 
-      @project.forked_project_link.destroy
       @project.fork_network_member.destroy
+      @project.forked_project_link.destroy
     end
 
     def refresh_forks_count(project)
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index dbaed1d09fbe9ad2afaf2034423d15112f4e29d5..2b23af9212ecfe539887638489ad1af0fed474a0 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -530,6 +530,32 @@
           = succeed "." do
             = link_to "repository storages documentation", help_page_path("administration/repository_storages")
 
+  %fieldset
+    %legend Git Storage Circuitbreaker settings
+    .form-group
+      = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2'
+      .col-sm-10
+        = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control'
+        .help-block
+          = circuitbreaker_failure_count_help_text
+    .form-group
+      = f.label :circuitbreaker_failure_wait_time, _('Seconds to wait after a storage failure'), class: 'control-label col-sm-2'
+      .col-sm-10
+        = f.number_field :circuitbreaker_failure_wait_time, class: 'form-control'
+        .help-block
+          = circuitbreaker_failure_wait_time_help_text
+    .form-group
+      = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2'
+      .col-sm-10
+        = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control'
+        .help-block
+          = circuitbreaker_failure_reset_time_help_text
+    .form-group
+      = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2'
+      .col-sm-10
+        = f.number_field :circuitbreaker_storage_timeout, class: 'form-control'
+        .help-block
+          = circuitbreaker_storage_timeout_help_text
 
   %fieldset
     %legend Repository Checks
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 7981daa070515637fa9bbdff5853f39a356cbbcd..cebdbab4e740e60db5a3d3e79fe7736a2d11b0dd 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,13 +1,13 @@
 .top-area
   %ul.nav-links
     = nav_link(page: dashboard_groups_path) do
-      = link_to dashboard_groups_path, title: 'Your groups' do
+      = link_to dashboard_groups_path, title: _("Your groups") do
         Your groups
     = nav_link(page: explore_groups_path) do
-      = link_to explore_groups_path, title: 'Explore public groups' do
+      = link_to explore_groups_path, title: _("Explore public groups") do
         Explore public groups
   .nav-controls
     = render 'shared/groups/search_form'
     = render 'shared/groups/dropdown'
     - if current_user.can_create_group?
-      = link_to "New group", new_group_path, class: "btn btn-new"
+      = link_to _("New group"), new_group_path, class: "btn btn-new"
diff --git a/app/views/dashboard/groups/_empty_state.html.haml b/app/views/dashboard/groups/_empty_state.html.haml
deleted file mode 100644
index f5222fe631e8c9ae988e09ea6afcf4144cc95857..0000000000000000000000000000000000000000
--- a/app/views/dashboard/groups/_empty_state.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.groups-empty-state
-  = custom_icon("icon_empty_groups")
-
-  .text-content
-    %h4 A group is a collection of several projects.
-    %p If you organize your projects under a group, it works like a folder.
-    %p You can manage your group member’s permissions and access to each project in the group.
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index 168e6272d8ea43e1c096d7c8bac118e145c42424..601b6a8b1a72d229233d74c6fbbf17de68727b07 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,9 +1,2 @@
 .js-groups-list-holder
-  #dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } }
-    .groups-list-loading
-      = icon('spinner spin', 'v-show' => 'isLoading')
-    %template{ 'v-if' => '!isLoading && isEmpty' }
-      %div{ 'v-cloak' => true }
-        = render 'empty_state'
-    %template{ 'v-else-if' => '!isLoading && !isEmpty' }
-      %groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' }
+  #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 1cea81827330dd0cc687abf434583b66e80a0948..25bf08c6c12f0c09f7f310f71a0888727771138f 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -6,7 +6,7 @@
 = webpack_bundle_tag 'common_vue'
 = webpack_bundle_tag 'groups'
 
-- if @groups.empty?
-  = render 'empty_state'
+- if params[:filter].blank? && @groups.empty?
+  = render 'shared/groups/empty_state'
 - else
   = render 'groups'
diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml
index 794c6d1d17069b9463b3c04a7535f1153aef4524..911494982481101e6c18a43f2c6a53de9dccb7e1 100644
--- a/app/views/explore/groups/_groups.html.haml
+++ b/app/views/explore/groups/_groups.html.haml
@@ -1,6 +1,2 @@
 .js-groups-list-holder
-  %ul.content-list
-    - @groups.each do |group|
-      = render 'shared/groups/group', group: group
-
-  = paginate @groups, theme: 'gitlab'
+  #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index 2651ef37e67d6c2cc95ff605c57c57e9a8829b68..86abdf547cc6356e99fc1003d35c488da435801b 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -2,6 +2,9 @@
 - page_title    "Groups"
 - header_title  "Groups", dashboard_groups_path
 
+= webpack_bundle_tag 'common_vue'
+= webpack_bundle_tag 'groups'
+
 - if current_user
   = render 'dashboard/groups_head'
 - else
@@ -17,7 +20,7 @@
       %p Below you will find all the groups that are public.
       %p You can easily contribute to them by requesting to join these groups.
 
-- if @groups.present?
-  = render 'groups'
-- else
+- if params[:filter].blank? && @groups.empty?
   .nothing-here-block No public groups
+- else
+  = render 'groups'
diff --git a/app/views/groups/_children.html.haml b/app/views/groups/_children.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..3afb6b2f849718d4b501d11d09b45e003a707c56
--- /dev/null
+++ b/app/views/groups/_children.html.haml
@@ -0,0 +1,5 @@
+= webpack_bundle_tag 'common_vue'
+= webpack_bundle_tag 'groups'
+
+.js-groups-list-holder
+  #js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/groups/_show_nav.html.haml b/app/views/groups/_show_nav.html.haml
deleted file mode 100644
index 35b75bc09237c9fcd60b45410d364ae8d5ede93e..0000000000000000000000000000000000000000
--- a/app/views/groups/_show_nav.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-%ul.nav-links
-  = nav_link(page: group_path(@group)) do
-    = link_to group_path(@group) do
-      Projects
-  - if Group.supports_nested_groups?
-    = nav_link(page: subgroups_group_path(@group)) do
-      = link_to subgroups_group_path(@group) do
-        Subgroups
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 3ca63f9c3e088ba190d1ab196fe9a9f848346094..7f9486d08d9fa2594b1fb81e059801640947cbd4 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,5 +1,6 @@
 - @no_container = true
 - breadcrumb_title "Details"
+- can_create_subgroups = can?(current_user, :create_subgroup, @group)
 
 = content_for :meta_tags do
   = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
@@ -7,13 +8,38 @@
 = render 'groups/home_panel'
 
 .groups-header{ class: container_class }
-  .top-area
-    = render 'groups/show_nav'
-    .nav-controls
-      = render 'shared/projects/search_form'
-      = render 'shared/projects/dropdown'
+  .group-nav-container
+    .nav-controls.clearfix
+      = render "shared/groups/search_form"
+      = render "shared/groups/dropdown", show_archive_options: true
       - if can? current_user, :create_projects, @group
-        = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do
-          New Project
+        - new_project_label = _("New project")
+        - new_subgroup_label = _("New subgroup")
+        - if can_create_subgroups
+          .btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
+            %input.btn.btn-success.dropdown-primary.js-new-group-child{ type: "button", value: new_project_label, data: { action: "new-project" } }
+            %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown" } }
+              = icon("caret-down", class: "dropdown-btn-icon")
+            %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
+              %li.droplab-item-selected{ role: "button", data: { value: "new-project", text: new_project_label } }
+                .menu-item
+                  .icon-container
+                    = icon("check", class: "list-item-checkmark")
+                  .description
+                    %strong= new_project_label
+                    %span= s_("GroupsTree|Create a project in this group.")
+              %li.divider.droplap-item-ignore
+              %li{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
+                .menu-item
+                  .icon-container
+                    = icon("check", class: "list-item-checkmark")
+                  .description
+                    %strong= new_subgroup_label
+                    %span= s_("GroupsTree|Create a subgroup in this group.")
+        - else
+          = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
 
-  = render "projects", projects: @projects
+  - if params[:filter].blank? && !@has_children
+    = render "shared/groups/empty_state"
+  - else
+    = render "children", children: @children, group: @group
diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml
deleted file mode 100644
index 869b3b243c6b85d07350aa0db6a6031b17f27aa4..0000000000000000000000000000000000000000
--- a/app/views/groups/subgroups.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-- breadcrumb_title "Details"
-- @no_container = true
-
-= render 'groups/home_panel'
-
-.groups-header{ class: container_class }
-  .top-area
-    = render 'groups/show_nav'
-    .nav-controls
-      = form_tag request.path, method: :get do |f|
-        = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false
-      - if can?(current_user, :create_subgroup, @group)
-        = link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do
-          New Subgroup
-
-  - if @nested_groups.present?
-    %ul.content-list
-      = render partial: 'shared/groups/group', collection: @nested_groups, locals: { full_name: false }
-  - else
-    .nothing-here-block
-      There are no subgroups to show.
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 4b344b2edb9ce9f47313e91a5d0371ecc2ca5e78..7777f55ddd763fce541f3c63252ed9a468004ebe 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -1,6 +1,6 @@
 - action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create'
 
-.file-holder.file.append-bottom-default
+.file-holder-bottom-radius.file-holder.file.append-bottom-default
   .js-file-title.file-title.clearfix{ data: { current_action: action } }
     .editor-ref
       = icon('code-fork')
diff --git a/app/views/projects/clusters/_advanced_settings.html.haml b/app/views/projects/clusters/_advanced_settings.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..6c162481dd8362d883e63eda85e78ba426d1f3a8
--- /dev/null
+++ b/app/views/projects/clusters/_advanced_settings.html.haml
@@ -0,0 +1,14 @@
+- if can?(current_user, :admin_cluster, @cluster)
+  .append-bottom-20
+    %label.append-bottom-10
+      = s_('ClusterIntegration|Google Container Engine')
+    %p
+      - link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
+      = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+
+  .well.form-group
+    %label.text-danger
+      = s_('ClusterIntegration|Remove cluster integration')
+    %p
+      = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.')
+    = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})
diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml
index aee6f904a62c1c89dee1f244ba91dd962cac1b75..ff76abc3553449bb884862c1f4ec512d56732f62 100644
--- a/app/views/projects/clusters/show.html.haml
+++ b/app/views/projects/clusters/show.html.haml
@@ -1,24 +1,37 @@
+- @content_class = "limit-container-width" unless fluid_layout
 - breadcrumb_title "Cluster"
 - page_title _("Cluster")
 
+- expanded = Rails.env.test?
+
 - status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation?
-.row.prepend-top-default.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
+.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
   toggle_status: @cluster.enabled? ? 'true': 'false',
   cluster_status: @cluster.status_name,
   cluster_status_reason: @cluster.status_reason } }
-  .col-sm-4
-    = render 'sidebar'
-  .col-sm-8
-    %label.append-bottom-10{ for: 'enable-cluster-integration' }
-      = s_('ClusterIntegration|Enable cluster integration')
-    %p
-      - if @cluster.enabled?
-        - if can?(current_user, :update_cluster, @cluster)
-          = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
+
+  %section.settings
+    %h4= s_('ClusterIntegration|Enable cluster integration')
+    .settings-content.expanded
+
+      .hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' }
+        = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
+        %p.js-error-reason
+
+      .hidden.js-cluster-creating.alert.alert-info.alert-block.append-bottom-10{ role: 'alert' }
+        = s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
+
+      .hidden.js-cluster-success.alert.alert-success.alert-block.append-bottom-10{ role: 'alert' }
+        = s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
+
+      %p
+        - if @cluster.enabled?
+          - if can?(current_user, :update_cluster, @cluster)
+            = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
+          - else
+            = s_('ClusterIntegration|Cluster integration is enabled for this project.')
         - else
-          = s_('ClusterIntegration|Cluster integration is enabled for this project.')
-      - else
-        = s_('ClusterIntegration|Cluster integration is disabled for this project.')
+          = s_('ClusterIntegration|Cluster integration is disabled for this project.')
 
     = form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field|
       = form_errors(@cluster)
@@ -36,35 +49,28 @@
           .form-group
             = field.submit s_('ClusterIntegration|Save'), class: 'btn btn-success'
 
-    - if can?(current_user, :admin_cluster, @cluster)
-      %label.append-bottom-10{ for: 'google-container-engine' }
-        = s_('ClusterIntegration|Google Container Engine')
-      %p
-        - link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
-        = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+  %section.settings#js-cluster-details
+    .settings-header
+      %h4= s_('ClusterIntegration|Cluster details')
+      %button.btn.js-settings-toggle
+        = expanded ? 'Collapse' : 'Expand'
+      %p= s_('ClusterIntegration|See and edit the details for your cluster')
 
-      .hidden.js-cluster-error.alert.alert-danger.alert-block{ role: 'alert' }
-        = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
-        %p.js-error-reason
-
-      .hidden.js-cluster-creating.alert.alert-info.alert-block{ role: 'alert' }
-        = s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
-
-      .hidden.js-cluster-success.alert.alert-success.alert-block{ role: 'alert' }
-        = s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
+    .settings-content.no-animate{ class: ('expanded' if expanded) }
 
-    .form_group.append-bottom-20
-      %label.append-bottom-10{ for: 'cluter-name' }
-        = s_('ClusterIntegration|Cluster name')
-      .input-group
-        %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
-        %span.input-group-addon.clipboard-addon
-          = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
+      .form_group.append-bottom-20
+        %label.append-bottom-10{ for: 'cluter-name' }
+          = s_('ClusterIntegration|Cluster name')
+        .input-group
+          %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
+          %span.input-group-addon.clipboard-addon
+            = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
 
-    - if can?(current_user, :admin_cluster, @cluster)
-      .well.form_group
-        %label.text-danger
-          = s_('ClusterIntegration|Remove cluster integration')
-        %p
-          = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.')
-        = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})
+  %section.settings#js-cluster-advanced-settings
+    .settings-header
+      %h4= s_('ClusterIntegration|Advanced settings')
+      %button.btn.js-settings-toggle
+        = expanded ? 'Collapse' : 'Expand'
+      %p= s_('ClusterIntegration|Manage Cluster integration on your GitLab project')
+    .settings-content.no-animate{ class: ('expanded' if expanded) }
+      = render 'advanced_settings'
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 3f3ce10419fa11ca44ef3ba4b79c90bede4fe2da..c9956183e12a97b9d68f694ca0b1fd47a81cbc3f 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -24,10 +24,15 @@
     %p
       You will need to be owner or have the master permission level for the initial push, as the master branch is automatically protected.
 
+    - if show_auto_devops_callout?(@project)
+      %p
+        - link = link_to(s_('AutoDevOps|Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'))
+        = s_('AutoDevOps|You can activate %{link_to_settings} for this project.').html_safe % { link_to_settings: link }
+      %p
+        = s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
+
 - if can?(current_user, :push_code, @project)
   %div{ class: container_class }
-    - if show_auto_devops_callout?(@project)
-      = render 'shared/auto_devops_callout'
     .prepend-top-20
     .empty_wrapper
       %h3.page-title-empty
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 6b8dcb3e60bab88dcca57df43a2f243de0fc8acb..8da2243adefdfa38c95a78c63f6d8383a3e033c9 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -13,8 +13,6 @@
 
 - if @project.merge_requests.exists?
   %div{ class: container_class }
-    - if show_auto_devops_callout?(@project)
-      = render 'shared/auto_devops_callout'
     .top-area
       = render 'shared/issuable/nav', type: :merge_requests
       .nav-controls
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 0a835dcdeb091360d99f19f082cd8e74376da16a..0a7880ce4cd65995fb6cd7754079afd4878fce70 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -54,6 +54,10 @@
                     = f.label :visibility_level, class: 'label-light' do #the label here seems wrong
                       Import project from
                     .import-buttons
+                      - if gitlab_project_import_enabled?
+                        .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
+                          = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
+                            = icon('gitlab', text: 'GitLab export')
                       %div
                         - if github_import_enabled?
                           = link_to new_import_github_path, class: 'btn import_github' do
@@ -87,10 +91,6 @@
                         - if git_import_enabled?
                           %button.btn.js-toggle-button.import_git{ type: "button" }
                             = icon('git', text: 'Repo by URL')
-                      - if gitlab_project_import_enabled?
-                        .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
-                          = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
-                            = icon('gitlab', text: 'GitLab export')
                 .col-lg-12
                   .js-toggle-content.hide.toggle-import-form
                     %hr
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index a10a7c239243612620d2071f22fa9cb39065b324..f8627a3818b47aa4232c39619177947c0e2e9d86 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -2,8 +2,6 @@
 - page_title "Pipelines"
 
 %div{ 'class' => container_class }
-  - if show_auto_devops_callout?(@project)
-    = render 'shared/auto_devops_callout'
   #pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
     "help-page-path" => help_page_path('ci/quick_start/README'),
     "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 0cc6674842a62f8a7ea5e45608a8e7c368bf8941..745a6040488ffd8123727086e146f26744c72440 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -12,7 +12,5 @@
     = webpack_bundle_tag 'repo'
 
 %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
-  - if show_auto_devops_callout?(@project) && !show_new_repo?
-    = render 'shared/auto_devops_callout'
   = render 'projects/last_push'
   = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index 7c633175a068edd47ddb01da7f1b6885242d5fc0..934d65e8b42f52382c517cfb221bcff9c2d2200a 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -1,15 +1,16 @@
-.user-callout{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
-  .bordered-box.landing.content-block
-    %button.btn.btn-default.close.js-close-callout{ type: 'button',
-      'aria-label' => 'Dismiss Auto DevOps box' }
-      = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
-    .svg-container
-      = custom_icon('icon_autodevops')
-    .user-callout-copy
-      %h4= s_('AutoDevOps|Auto DevOps (Beta)')
-      %p= s_('AutoDevOps|Auto DevOps can be activated for this project. It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
-      %p
-        - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
-        = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
+.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
+  .banner-graphic
+    = custom_icon('icon_autodevops')
 
-      = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn btn-primary js-close-callout'
+  .prepend-top-10.prepend-left-10.append-bottom-10
+    %h5= s_('AutoDevOps|Auto DevOps (Beta)')
+    %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
+    %p
+      - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
+      = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
+    .prepend-top-10
+      = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn js-close-callout'
+
+  %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button',
+  'aria-label' => 'Dismiss Auto DevOps box' }
+    = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 760370a6984560602ff022412a455e85fb46f2e3..8e6747ca74034bbd7a2c5b7df142b9e50977d640 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -1,18 +1,32 @@
-.dropdown.inline.js-group-filter-dropdown-wrap
+- show_archive_options = local_assigns.fetch(:show_archive_options, false)
+- if @sort.present?
+  - default_sort_by = @sort
+- else
+  - if params[:sort]
+    - default_sort_by = params[:sort]
+  - else
+    - default_sort_by = sort_value_recently_created
+
+.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10
   %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
     %span.dropdown-label
-      - if @sort.present?
-        = sort_options_hash[@sort]
-      - else
-        = sort_title_recently_created
+      = sort_options_hash[default_sort_by]
     = icon('chevron-down')
-  %ul.dropdown-menu.dropdown-menu-align-right
-    %li
-      = link_to filter_groups_path(sort: sort_value_recently_created) do
-        = sort_title_recently_created
-      = link_to filter_groups_path(sort: sort_value_oldest_created) do
-        = sort_title_oldest_created
-      = link_to filter_groups_path(sort: sort_value_recently_updated) do
-        = sort_title_recently_updated
-      = link_to filter_groups_path(sort: sort_value_oldest_updated) do
-        = sort_title_oldest_updated
+  %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
+    %li.dropdown-header
+      = _("Sort by")
+    - groups_sort_options_hash.each do |value, title|
+      %li.js-filter-sort-order
+        = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do
+          = title
+    - if show_archive_options
+      %li.divider
+      %li.js-filter-archived-projects
+        = link_to group_children_path(@group, archived: nil), class: ("is-active" unless params[:archived].present?) do
+          Hide archived projects
+      %li.js-filter-archived-projects
+        = link_to group_children_path(@group, archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
+          Show archived projects
+      %li.js-filter-archived-projects
+        = link_to group_children_path(@group, archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
+          Show archived projects only
diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..13bb4baee3f203b7e4558e12194edf027a509c4b
--- /dev/null
+++ b/app/views/shared/groups/_empty_state.html.haml
@@ -0,0 +1,7 @@
+.groups-empty-state
+  = custom_icon("icon_empty_groups")
+
+  .text-content
+    %h4= s_("GroupsEmptyState|A group is a collection of several projects.")
+    %p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
+    %p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.")
diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml
index 63f62eb476e5fe2da8e5cc75bd2b5e6637f0ea80..059dd24be6d74312822673c24e85a84c2142327e 100644
--- a/app/views/shared/groups/_group.html.haml
+++ b/app/views/shared/groups/_group.html.haml
@@ -11,7 +11,7 @@
         = link_to edit_group_path(group), class: "btn" do
           = icon('cogs')
 
-      = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do
+      = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: s_("GroupsTree|Leave this group") do
         = icon('sign-out')
 
   .stats
diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index 427595c47a584bfd392381f9c8812f2adb5d3550..aec8ecd17143e733491d6980da1898256ab87208 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -3,4 +3,4 @@
     - groups.each_with_index do |group, i|
       = render "shared/groups/group", group: group
 - else
-  .nothing-here-block No groups found
+  .nothing-here-block= s_("GroupsEmptyState|No groups found")
diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml
index ad7a7faedf1d0443a69d03ae3fbcf6916f3692ea..3f91263089ad23047eb49e80c391c6c227bd33a2 100644
--- a/app/views/shared/groups/_search_form.html.haml
+++ b/app/views/shared/groups/_search_form.html.haml
@@ -1,2 +1,2 @@
-= form_tag request.path, method: :get, class: 'group-filter-form', id: 'group-filter-form' do |f|
-  = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name...', class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
+= form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f|
+  = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Filter by name...'), class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
diff --git a/app/views/shared/icons/_icon_autodevops.svg b/app/views/shared/icons/_icon_autodevops.svg
index 807ff27bb674a3678803539ff9cedacd658330db..7e47c084bde6e1c75884e20bc17c487f32aa0038 100644
--- a/app/views/shared/icons/_icon_autodevops.svg
+++ b/app/views/shared/icons/_icon_autodevops.svg
@@ -1,4 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="189" height="179" viewBox="0 0 189 179">
+<svg xmlns="http://www.w3.org/2000/svg" width="189" height="110" viewBox="0 0 189 179">
   <g fill="none" fill-rule="evenodd">
     <path fill="#FFFFFF" fill-rule="nonzero" d="M110.160166,47.6956996 L160.160166,47.6956996 C165.683013,47.6956996 170.160166,52.1728521 170.160166,57.6956996 L170.160166,117.6957 C170.160166,123.218547 165.683013,127.6957 160.160166,127.6957 L110.160166,127.6957 C104.637318,127.6957 100.160166,123.218547 100.160166,117.6957 L100.160166,57.6956996 C100.160166,52.1728521 104.637318,47.6956996 110.160166,47.6956996 Z" transform="rotate(10 135.16 87.696)"/>
     <path fill="#EEEEEE" fill-rule="nonzero" d="M110.160166,51.6956996 C106.846457,51.6956996 104.160166,54.3819911 104.160166,57.6956996 L104.160166,117.6957 C104.160166,121.009408 106.846457,123.6957 110.160166,123.6957 L160.160166,123.6957 C163.473874,123.6957 166.160166,121.009408 166.160166,117.6957 L166.160166,57.6956996 C166.160166,54.3819911 163.473874,51.6956996 160.160166,51.6956996 L110.160166,51.6956996 Z M110.160166,47.6956996 L160.160166,47.6956996 C165.683013,47.6956996 170.160166,52.1728521 170.160166,57.6956996 L170.160166,117.6957 C170.160166,123.218547 165.683013,127.6957 160.160166,127.6957 L110.160166,127.6957 C104.637318,127.6957 100.160166,123.218547 100.160166,117.6957 L100.160166,57.6956996 C100.160166,52.1728521 104.637318,47.6956996 110.160166,47.6956996 Z" transform="rotate(10 135.16 87.696)"/>
diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml
index 80432a73e4e1a5355a6d52ac25dd268ee2b63355..3d917346f6b879664b8065f6ae9240316314b4a3 100644
--- a/app/views/shared/projects/_dropdown.html.haml
+++ b/app/views/shared/projects/_dropdown.html.haml
@@ -1,5 +1,5 @@
 - @sort ||= sort_value_latest_activity
-.dropdown
+.dropdown.js-project-filter-dropdown-wrap
   - toggle_text = projects_sort_options_hash[@sort]
   = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' })
   %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml
index 919f19f2c23a3f6c1a83cfb486e20ba1eff134df..7185f5bcc5b0e577a60b350773f0bae1a36eb88c 100644
--- a/app/views/shared/repo/_repo.html.haml
+++ b/app/views/shared/repo/_repo.html.haml
@@ -1,4 +1,5 @@
-#repo{ data: { url: content_url,
+#repo{ data: { root: @path.empty?.to_s,
+               url: content_url,
                project_name: project.name,
                refs_url: refs_project_path(project, format: :json),
                project_url: project_path(project),
diff --git a/bin/changelog b/bin/changelog
index 61d4de06e90c2aa106163ebf8479c7d0792021fb..efe25032ba128883ab111d42757850a5e232a1fc 100755
--- a/bin/changelog
+++ b/bin/changelog
@@ -28,6 +28,7 @@ class ChangelogOptionParser
     Type.new('deprecated', 'New deprecation'),
     Type.new('removed', 'Feature removal'),
     Type.new('security', 'Security fix'),
+    Type.new('performance', 'Performance improvement'),
     Type.new('other', 'Other')
   ].freeze
   TYPES_OFFSET = 1
diff --git a/changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml b/changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml
new file mode 100644
index 0000000000000000000000000000000000000000..554249a3f88d9fc05f5a0327b0bd4c5df321a53b
--- /dev/null
+++ b/changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml
@@ -0,0 +1,6 @@
+---
+title: Removed extra border radius from .file-editor and .file-holder when editing
+  a file
+merge_request: 14803
+author: Rachel Pipkin
+type: fixed
diff --git a/changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml b/changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml
new file mode 100644
index 0000000000000000000000000000000000000000..89506f88637d17bf751e2dd0e5ed7e84b1f315ce
--- /dev/null
+++ b/changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml
@@ -0,0 +1,5 @@
+---
+title: Make usage ping scheduling more robust
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml b/changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4b90d68d80c3e52f4a66e2a4832619762fd1abec
--- /dev/null
+++ b/changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml
@@ -0,0 +1,5 @@
+---
+title: 14830 Move GitLab export option to top of import list when creating a new project
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/bvl-circuitbreaker-improvements.yml b/changelogs/unreleased/bvl-circuitbreaker-improvements.yml
new file mode 100644
index 0000000000000000000000000000000000000000..15cbd5592e9bde1ffb176db7326d0af1dc266f92
--- /dev/null
+++ b/changelogs/unreleased/bvl-circuitbreaker-improvements.yml
@@ -0,0 +1,5 @@
+---
+title: Store circuitbreaker settings in the database instead of config
+merge_request: 14842
+author:
+type: changed
diff --git a/changelogs/unreleased/bvl-do-not-use-redis-keys.yml b/changelogs/unreleased/bvl-do-not-use-redis-keys.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f703aad20659ec09824cf3a57cb539e9ae0642b4
--- /dev/null
+++ b/changelogs/unreleased/bvl-do-not-use-redis-keys.yml
@@ -0,0 +1,5 @@
+---
+title: Forbid the usage of `Redis#keys`
+merge_request: 14889
+author:
+type: fixed
diff --git a/changelogs/unreleased/bvl-fix-deleting-forked-projects.yml b/changelogs/unreleased/bvl-fix-deleting-forked-projects.yml
new file mode 100644
index 0000000000000000000000000000000000000000..95f56facc4ba8e8e4dcd8676682eadf4476759af
--- /dev/null
+++ b/changelogs/unreleased/bvl-fix-deleting-forked-projects.yml
@@ -0,0 +1,5 @@
+---
+title: Fix error when updating a forked project with deleted `ForkedProjectLink`
+merge_request: 14916
+author:
+type: fixed
diff --git a/changelogs/unreleased/bvl-group-trees.yml b/changelogs/unreleased/bvl-group-trees.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9f76eb8162770fdbf52151d2c4ad5dae9c62f8df
--- /dev/null
+++ b/changelogs/unreleased/bvl-group-trees.yml
@@ -0,0 +1,5 @@
+---
+title: Show collapsible project lists
+merge_request: 14055
+author:
+type: changed
diff --git a/changelogs/unreleased/fix_global_board_routes_39073.yml b/changelogs/unreleased/fix_global_board_routes_39073.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cc9ae8592db831f5b9f67c36f163824fb32365ad
--- /dev/null
+++ b/changelogs/unreleased/fix_global_board_routes_39073.yml
@@ -0,0 +1,5 @@
+---
+title: Allow boards as top level route
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fl-autodevops-fix.yml b/changelogs/unreleased/fl-autodevops-fix.yml
new file mode 100644
index 0000000000000000000000000000000000000000..21b739231a8374dbad1e75a74abef4c071e0200d
--- /dev/null
+++ b/changelogs/unreleased/fl-autodevops-fix.yml
@@ -0,0 +1,5 @@
+---
+title: Improve autodevops banner UX and render it only in project page
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/prevent-creating-multiple-application-settings.yml b/changelogs/unreleased/prevent-creating-multiple-application-settings.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fd49028b9e9c8e39bc3f3b965f7496c1c423949a
--- /dev/null
+++ b/changelogs/unreleased/prevent-creating-multiple-application-settings.yml
@@ -0,0 +1,5 @@
+---
+title: Prevent creating multiple ApplicationSetting instances
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/sha-handling.yml b/changelogs/unreleased/sha-handling.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d776edafef54e638629ceae40488244c70c8e920
--- /dev/null
+++ b/changelogs/unreleased/sha-handling.yml
@@ -0,0 +1,5 @@
+---
+title: Fix 404 errors in API caused when the branch name had a dot
+merge_request: 14462
+author: gvieira37
+type: fixed
diff --git a/changelogs/unreleased/zj-add-performance-changelog-cat.yml b/changelogs/unreleased/zj-add-performance-changelog-cat.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3d58044a254ff9e621fd7bbdb3a735b98281e72d
--- /dev/null
+++ b/changelogs/unreleased/zj-add-performance-changelog-cat.yml
@@ -0,0 +1,5 @@
+---
+title: Add Performance improvement as category on the changelog
+merge_request:
+author:
+type: performance
diff --git a/config/application.rb b/config/application.rb
index 31e91835b9eb1b207cae4df6d5e7a390a2685525..5100ec5d2b7ea724f5d89ad9ec01632246bf2b67 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -29,6 +29,7 @@ module Gitlab
                                      #{config.root}/app/models/project_services
                                      #{config.root}/app/workers/concerns
                                      #{config.root}/app/services/concerns
+                                     #{config.root}/app/serializers/concerns
                                      #{config.root}/app/finders/concerns])
 
     config.generators.templates.push("#{config.root}/generator_templates")
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 1069c7be5f0c1c877b9bb7a9e95bd88a417d1f24..4bfa5be01369c510f7b38821d44427885cb206be 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -522,11 +522,6 @@ production: &base
         path: /home/git/repositories/
         gitaly_address: unix:/home/git/gitlab/tmp/sockets/private/gitaly.socket # TCP connections are supported too (e.g. tcp://host:port)
         # gitaly_token: 'special token' # Optional: override global gitaly.token for this storage.
-        failure_count_threshold: 10 # number of failures before stopping attempts
-        failure_wait_time: 30 # Seconds after an access failure before allowing access again
-        failure_reset_time: 1800 # Time in seconds to expire failures
-        storage_timeout: 30 # Time in seconds to wait before aborting a storage access attempt
-
 
   ## Backup settings
   backup:
@@ -659,9 +654,6 @@ test:
       default:
         path: tmp/tests/repositories/
         gitaly_address: unix:tmp/tests/gitaly/gitaly.socket
-        failure_count_threshold: 999999
-        failure_wait_time: 0
-        storage_timeout: 30
       broken:
         path: tmp/tests/non-existent-repositories
         gitaly_address: unix:tmp/tests/gitaly/gitaly.socket
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index a4b7c1a39196b705bb86f431b176644ac1e3ba01..12694f8016f524a4d0ca64e95667be431b54123d 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -113,12 +113,14 @@ class Settings < Settingslogic
       URI.parse(url_without_path).host
     end
 
-    # Random cron time every Sunday to load balance usage pings
-    def cron_random_weekly_time
+    # Runs every minute in a random ten-minute period on Sundays, to balance the
+    # load on the server receiving these pings. The usage ping is safe to run
+    # multiple times because of a 24 hour exclusive lock.
+    def cron_for_usage_ping
       hour = rand(24)
-      minute = rand(60)
+      minute = rand(6)
 
-      "#{minute} #{hour} * * 0"
+      "#{minute}0-#{minute}9 #{hour} * * 0"
     end
   end
 end
@@ -398,7 +400,7 @@ Settings.cron_jobs['stuck_import_jobs_worker'] ||= Settingslogic.new({})
 Settings.cron_jobs['stuck_import_jobs_worker']['cron'] ||= '15 * * * *'
 Settings.cron_jobs['stuck_import_jobs_worker']['job_class'] = 'StuckImportJobsWorker'
 Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({})
-Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_random_weekly_time)
+Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_for_usage_ping)
 Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker'
 
 Settings.cron_jobs['schedule_update_user_activity_worker'] ||= Settingslogic.new({})
@@ -453,17 +455,6 @@ Settings.repositories.storages.each do |key, storage|
 
   # Expand relative paths
   storage['path'] = Settings.absolute(storage['path'])
-  # Set failure defaults
-  storage['failure_count_threshold'] ||= 10
-  storage['failure_wait_time'] ||= 30
-  storage['failure_reset_time'] ||= 1800
-  storage['storage_timeout'] ||= 5
-  # Set turn strings into numbers
-  storage['failure_count_threshold'] = storage['failure_count_threshold'].to_i
-  storage['failure_wait_time'] = storage['failure_wait_time'].to_i
-  storage['failure_reset_time'] = storage['failure_reset_time'].to_i
-  # We might want to have a timeout shorter than 1 second.
-  storage['storage_timeout'] = storage['storage_timeout'].to_f
 
   Settings.repositories.storages[key] = storage
 end
diff --git a/config/routes.rb b/config/routes.rb
index 405bfcc2d8e3298f11f72fd1034db7703818b04a..fc13dc4865f098caa4d69334fa3803c74aad13d2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -44,6 +44,19 @@ Rails.application.routes.draw do
     get 'readiness' => 'health#readiness'
     resources :metrics, only: [:index]
     mount Peek::Railtie => '/peek'
+
+    # Boards resources shared between group and projects
+    resources :boards, only: [] do
+      resources :lists, module: :boards, only: [:index, :create, :update, :destroy] do
+        collection do
+          post :generate
+        end
+
+        resources :issues, only: [:index, :create, :update]
+      end
+
+      resources :issues, module: :boards, only: [:index, :update]
+    end
   end
 
   # Koding route
@@ -74,19 +87,6 @@ Rails.application.routes.draw do
   # Notification settings
   resources :notification_settings, only: [:create, :update]
 
-  # Boards resources shared between group and projects
-  resources :boards do
-    resources :lists, module: :boards, only: [:index, :create, :update, :destroy] do
-      collection do
-        post :generate
-      end
-
-      resources :issues, only: [:index, :create, :update]
-    end
-
-    resources :issues, module: :boards, only: [:index, :update]
-  end
-
   draw :google_api
   draw :import
   draw :uploads
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 8cc30bfcc5013262c9e2ef66c7e8a5a78cf925fc..702df5b7b5a6159ca0bb449c931f87d8c50f38d5 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -32,6 +32,8 @@ scope(path: 'groups/*group_id',
     end
 
     resources :variables, only: [:index, :show, :update, :create, :destroy]
+
+    resources :children, only: [:index]
   end
 end
 
@@ -43,7 +45,6 @@ scope(path: 'groups/*id',
   get :merge_requests, as: :merge_requests_group
   get :projects, as: :projects_group
   get :activity, as: :activity_group
-  get :subgroups, as: :subgroups_group
   get '/', action: :show, as: :group_canonical
 end
 
diff --git a/config/webpack.config.js b/config/webpack.config.js
index a71794b379d112c393d4a31f188ada1d711b57c0..f7a7182a6270f8067d95a6797ede01a8b76b8ffe 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -236,7 +236,7 @@ var config = {
         from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`),
         to: 'monaco-editor/vs',
         transform: function(content, path) {
-          if (/\.js$/.test(path) && !/worker/i.test(path)) {
+          if (/\.js$/.test(path) && !/worker/i.test(path) && !/typescript/i.test(path)) {
             return (
               '(function(){\n' +
               'var define = this.define, require = this.require;\n' +
diff --git a/db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb b/db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bcf7dbd8e6415ff9d70d36bbdcb8aa26391fe3dd
--- /dev/null
+++ b/db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb
@@ -0,0 +1,27 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddCircuitBreakerPropertiesToApplicationSettings < ActiveRecord::Migration
+  include Gitlab::Database::MigrationHelpers
+
+  DOWNTIME = false
+
+  def change
+    add_column :application_settings,
+               :circuitbreaker_failure_count_threshold,
+               :integer,
+               default: 160
+    add_column :application_settings,
+               :circuitbreaker_failure_wait_time,
+               :integer,
+               default: 30
+    add_column :application_settings,
+               :circuitbreaker_failure_reset_time,
+               :integer,
+               default: 1800
+    add_column :application_settings,
+               :circuitbreaker_storage_timeout,
+               :integer,
+               default: 30
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8aadcfeb7d1ee723ddc26e76a41cd337fd3435be..c2c04873d4d83b24ded26eae4d86791ded720387 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20171006091000) do
+ActiveRecord::Schema.define(version: 20171012101043) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -134,6 +134,10 @@ ActiveRecord::Schema.define(version: 20171006091000) do
     t.boolean "hashed_storage_enabled", default: false, null: false
     t.boolean "project_export_enabled", default: true, null: false
     t.boolean "auto_devops_enabled", default: false, null: false
+    t.integer "circuitbreaker_failure_count_threshold", default: 160
+    t.integer "circuitbreaker_failure_wait_time", default: 30
+    t.integer "circuitbreaker_failure_reset_time", default: 1800
+    t.integer "circuitbreaker_storage_timeout", default: 30
   end
 
   create_table "audit_events", force: :cascade do |t|
diff --git a/doc/administration/img/circuitbreaker_config.png b/doc/administration/img/circuitbreaker_config.png
new file mode 100644
index 0000000000000000000000000000000000000000..9250d38297c85834c90f2f682389c1947546baba
Binary files /dev/null and b/doc/administration/img/circuitbreaker_config.png differ
diff --git a/doc/administration/raketasks/github_import.md b/doc/administration/raketasks/github_import.md
index 04c70c3644ec741ecb22ac8d9ee7d13c3d95c37a..6b8ad1b039bc38cc4a20b43946dc28b862f6f157 100644
--- a/doc/administration/raketasks/github_import.md
+++ b/doc/administration/raketasks/github_import.md
@@ -7,6 +7,7 @@
 >    projects. You can get it from: https://github.com/settings/tokens
 >  - You also need to pass an username as the second argument to the rake task
 >    which will become the owner of the project.
+>  - You can also resume an import with the same command.
 
 To import a project from the list of your GitHub projects available:
 
diff --git a/doc/administration/repository_storage_paths.md b/doc/administration/repository_storage_paths.md
index 624a908b3a3b891ae31dd02f4decd05601a61fe5..efcabd69822ad49b96321fd54fbb1a1d637dfe87 100644
--- a/doc/administration/repository_storage_paths.md
+++ b/doc/administration/repository_storage_paths.md
@@ -105,61 +105,26 @@ When GitLab detects access to the repositories storage fails repeatedly, it can
 gracefully prevent attempts to access the storage. This might be useful when
 the repositories are stored somewhere on the network.
 
-The configuration could look as follows:
+This can be configured from the admin interface:
 
-**For Omnibus installations**
-
-1. Edit `/etc/gitlab/gitlab.rb`:
-
-    ```ruby
-    git_data_dirs({
-      "default" => {
-        "path" => "/mnt/nfs-01/git-data",
-        "failure_count_threshold" => 10,
-        "failure_wait_time" => 30,
-        "failure_reset_time" => 1800,
-        "storage_timeout" => 5
-       }
-    })
-    ```
-
-1. Save the file and [reconfigure GitLab][reconfigure-gitlab] for the changes to take effect.
-
----
-
-**For installations from source**
-
-1. Edit `config/gitlab.yml`:
-
-    ```yaml
-    repositories:
-      storages: # You must have at least a `default` storage path.
-        default:
-          path: /home/git/repositories/
-          failure_count_threshold: 10 # number of failures before stopping attempts
-          failure_wait_time: 30 # Seconds after last access failure before trying again
-          failure_reset_time: 1800 # Time in seconds to expire failures
-          storage_timeout: 5 # Time in seconds to wait before aborting a storage access attempt
-    ```
-
-1. Save the file and [restart GitLab][restart-gitlab] for the changes to take effect.
+![circuitbreaker configuration](img/circuitbreaker_config.png)
 
 
-**`failure_count_threshold`:** The number of failures of after which GitLab will
+**Maximum git storage failures:** The number of failures of after which GitLab will
 completely prevent access to the storage. The number of failures can be reset in
 the admin interface: `https://gitlab.example.com/admin/health_check` or using the
 [api](../api/repository_storage_health.md) to allow access to the storage again.
 
-**`failure_wait_time`:** When access to a storage fails. GitLab will prevent
-access to the storage for the time specified here. This allows the filesystem to
-recover without.
+**Seconds to wait after a storage failure:** When access to a storage fails. GitLab
+will prevent access to the storage for the time specified here. This allows the
+filesystem to recover.
 
-**`failure_reset_time`:** The time in seconds GitLab will keep failure
-information. When no failures occur during this time, information about the
+**Seconds before reseting failure information:** The time in seconds GitLab will
+keep failure information. When no failures occur during this time, information about the
 mount is reset.
 
-**`storage_timeout`:** The time in seconds GitLab will try to access storage.
-After this time a timeout error will be raised.
+**Seconds to wait for a storage access attempt:** The time in seconds GitLab will
+try to access storage. After this time a timeout error will be raised.
 
 When storage failures occur, this will be visible in the admin interface like this:
 
diff --git a/doc/api/repositories.md b/doc/api/repositories.md
index bccef92437529fb2788dbc7556899ae49673d4fa..594babc74be33cf3e4ff2ce2f46b5c4160caef41 100644
--- a/doc/api/repositories.md
+++ b/doc/api/repositories.md
@@ -85,7 +85,7 @@ GET /projects/:id/repository/blobs/:sha
 Parameters:
 
 - `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
-- `sha` (required) - The commit or branch name
+- `sha` (required) - The blob SHA
 
 ## Raw blob content
 
diff --git a/doc/api/settings.md b/doc/api/settings.md
index be75e1b66ba4d5124ae541485f45e0a61d5ae292..664f3ef7b77791a2ed86758d10c4889d29fd04bb 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -69,6 +69,10 @@ PUT /application/settings
 | `after_sign_up_text`                     | string           | no                                            | Text shown to the user after signing up                                                                                                                                                                                                                                                                                                                                                                                                                   |
 | `akismet_api_key`                        | string           | no                                            | API key for akismet spam protection                                                                                                                                                                                                                                                                                                                                                                                                                       |
 | `akismet_enabled`                        | boolean          | no                                            | Enable or disable akismet spam protection                                                                                                                                                                                                                                                                                                                                                                                                                 |
+| `circuitbreaker_failure_count_threshold` | integer          | no                                            | The number of failures of after which GitLab will completely prevent access to the storage.                                                                                                                                                                                                                                                                                                                                                               |
+| `circuitbreaker_failure_reset_time`      | integer          | no                                            | Time in seconds GitLab will keep storage failure information. When no failures occur during this time, the failure information is reset.                                                                                                                                                                                                                                                                                                                  |
+| `circuitbreaker_failure_wait_time`       | integer          | no                                            | Time in seconds GitLab will block access to a failing storage to allow it to recover.                                                                                                                                                                                                                                                                                                                                                                     |
+| `circuitbreaker_storage_timeout`         | integer          | no                                            | Seconds to wait for a storage access attempt                                                                                                                                                                                                                                                                                                                                                                                                              |
 | `clientside_sentry_dsn`                  | string           | no                                            | Required if `clientside_sentry_dsn` is enabled                                                                                                                                                                                                                                                                                                                                                                                                            |
 | `clientside_sentry_enabled`              | boolean          | no                                            | Enable Sentry error reporting for the client side                                                                                                                                                                                                                                                                                                                                                                                                         |
 | `container_registry_token_expire_delay`  | integer          | no                                            | Container Registry token duration in minutes                                                                                                                                                                                                                                                                                                                                                                                                              |
diff --git a/doc/ci/README.md b/doc/ci/README.md
index 5cfd82de38153c828f6ebb0bf63d560d004b5123..ec0ddfbea753bfe50d53d27756392ae676f9f4d6 100644
--- a/doc/ci/README.md
+++ b/doc/ci/README.md
@@ -42,7 +42,7 @@ digging into specific reference guides.
 - **The permissions model** - Learn about the access levels a user can have for
   performing certain CI actions
   - [User permissions](../user/permissions.md#gitlab-ci)
-  - [Jobs permissions](../user/permissions.md#jobs-permissions)
+  - [Job permissions](../user/permissions.md#job-permissions)
 
 ## Auto DevOps
 
diff --git a/doc/development/gitaly.md b/doc/development/gitaly.md
index e41d258bec6777d820007a8ce630fa9a88698f52..ca2048c70198a6ca9d311631372ed59138b31fea 100644
--- a/doc/development/gitaly.md
+++ b/doc/development/gitaly.md
@@ -52,8 +52,8 @@ rm -rf tmp/tests/gitaly
 
 ## `TooManyInvocationsError` errors
 
-During development and testing, you may experience `Gitlab::GitalyClient::TooManyInvocationsError` failures. 
-The `GitalyClient` will attempt to block against potential n+1 issues by raising this error 
+During development and testing, you may experience `Gitlab::GitalyClient::TooManyInvocationsError` failures.
+The `GitalyClient` will attempt to block against potential n+1 issues by raising this error
 when Gitaly is called more than 30 times in a single Rails request or Sidekiq execution.
 
 As a temporary measure, export `GITALY_DISABLE_REQUEST_LIMITS=1` to suppress the error. This will disable the n+1 detection
@@ -64,7 +64,7 @@ Please raise an issue in the GitLab CE or EE repositories to report the issue. I
 `TooManyInvocationsError`. Also include any known failing tests if possible.
 
 Isolate the source of the n+1 problem. This will normally be a loop that results in Gitaly being called for each
-element in an array. If you are unable to isolate the problem, please contact a member 
+element in an array. If you are unable to isolate the problem, please contact a member
 of the [Gitaly Team](https://gitlab.com/groups/gl-gitaly/group_members) for assistance.
 
 Once the source has been found, wrap it in an `allow_n_plus_1_calls` block, as follows:
@@ -79,6 +79,24 @@ end
 
 Once the code is wrapped in this block, this code-path will be excluded from n+1 detection.
 
+## Request counts
+
+Commits and other git data, is now fetched through Gitaly. These fetches can,
+much like with a database, be batched. This improves performance for the client
+and for Gitaly itself and therefore for the users too. To keep performance stable
+and guard performance regressions, Gitaly calls can be counted and the call count
+can be tested against. This requires the `:request_store` flag to be set.
+
+```ruby
+describe 'Gitaly Request count tests' do
+  context 'when the request store is activated', :request_store do
+    it 'correctly counts the gitaly requests made' do
+      expect { subject }.to change { Gitlab::GitalyClient.get_request_count }.by(10)
+    end
+  end
+end
+```
+
 ---
 
 [Return to Development documentation](README.md)
diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md
index 67ef189fee9d869e0bbb9d281ad93a5e9c503f6e..e18711f3392664c8d1beeb8a8b40fa33ca97dcf0 100644
--- a/doc/gitlab-basics/create-project.md
+++ b/doc/gitlab-basics/create-project.md
@@ -17,7 +17,7 @@
    [Project Templates](https://gitlab.com/gitlab-org/project-templates):
    this will kickstart your repository code and CI automatically.
    Otherwise, if you have a project in a different repository, you can [import it] by
-   clicking an **Import project from** button provided this is enabled in
+   clicking on the **Import project** tab, provided this is enabled in
    your GitLab instance. Ask your administrator if not.
 
 1. Provide the following information:
diff --git a/doc/gitlab-basics/img/create_new_project_info.png b/doc/gitlab-basics/img/create_new_project_info.png
index ef8753e224beb6166cad5dff7ad26c60bf217948..ce4f7d1204b52be5c9ac9c22504834d4b7665939 100644
Binary files a/doc/gitlab-basics/img/create_new_project_info.png and b/doc/gitlab-basics/img/create_new_project_info.png differ
diff --git a/doc/install/installation.md b/doc/install/installation.md
index 2c93297ca2f600c7869e4c13bde123d734f0dd6a..2a004152d5e60ff4dc12ac7c67a0f5594a878258 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -80,7 +80,7 @@ Make sure you have the right version of Git installed
     # Install Git
     sudo apt-get install -y git-core
 
-    # Make sure Git is version 2.13.0 or higher
+    # Make sure Git is version 2.13.6 or higher
     git --version
 
 Is the system packaged Git too old? Remove it and compile from source.
diff --git a/doc/user/project/issues/automatic_issue_closing.md b/doc/user/project/issues/automatic_issue_closing.md
index 10dede255ecb05082b158d0c1c7b7ae4bbc0ae4d..402a2a3c727205e82995e95817847d929e308ff5 100644
--- a/doc/user/project/issues/automatic_issue_closing.md
+++ b/doc/user/project/issues/automatic_issue_closing.md
@@ -1,8 +1,10 @@
 # Automatic issue closing
 
->**Note:**
-This is the user docs. In order to change the default issue closing pattern,
-follow the steps in the [administration docs].
+>**Notes:**
+> - This is the user docs. In order to change the default issue closing pattern,
+>   follow the steps in the [administration docs].
+> - For performance reasons, automatic issue closing is disabled for the very
+>   first push from an existing repository.
 
 When a commit or merge request resolves one or more issues, it is possible to
 automatically have these issues closed when the commit or merge request lands
diff --git a/features/explore/groups.feature b/features/explore/groups.feature
index 9eacbe0b25e8bb09089f393d41166bf1d3e1cd90..830810615e0b872803b058397290d97116793cff 100644
--- a/features/explore/groups.feature
+++ b/features/explore/groups.feature
@@ -3,6 +3,7 @@ Feature: Explore Groups
   Background:
     Given group "TestGroup" has private project "Enterprise"
 
+  @javascript
   Scenario: I should see group with private and internal projects as user
     Given group "TestGroup" has internal project "Internal"
     When I sign in as a user
@@ -10,6 +11,7 @@ Feature: Explore Groups
     Then I should see project "Internal" items
     And I should not see project "Enterprise" items
 
+  @javascript
   Scenario: I should see group issues for internal project as user
     Given group "TestGroup" has internal project "Internal"
     When I sign in as a user
@@ -17,6 +19,7 @@ Feature: Explore Groups
     Then I should see project "Internal" items
     And I should not see project "Enterprise" items
 
+  @javascript
   Scenario: I should see group merge requests for internal project as user
     Given group "TestGroup" has internal project "Internal"
     When I sign in as a user
@@ -24,6 +27,7 @@ Feature: Explore Groups
     Then I should see project "Internal" items
     And I should not see project "Enterprise" items
 
+  @javascript
   Scenario: I should see group with private, internal and public projects as visitor
     Given group "TestGroup" has internal project "Internal"
     Given group "TestGroup" has public project "Community"
@@ -32,6 +36,7 @@ Feature: Explore Groups
     And I should not see project "Internal" items
     And I should not see project "Enterprise" items
 
+  @javascript
   Scenario: I should see group issues for public project as visitor
     Given group "TestGroup" has internal project "Internal"
     Given group "TestGroup" has public project "Community"
@@ -40,6 +45,7 @@ Feature: Explore Groups
     And I should not see project "Internal" items
     And I should not see project "Enterprise" items
 
+  @javascript
   Scenario: I should see group merge requests for public project as visitor
     Given group "TestGroup" has internal project "Internal"
     Given group "TestGroup" has public project "Community"
@@ -48,6 +54,7 @@ Feature: Explore Groups
     And I should not see project "Internal" items
     And I should not see project "Enterprise" items
 
+  @javascript
   Scenario: I should see group with private, internal and public projects as user
     Given group "TestGroup" has internal project "Internal"
     Given group "TestGroup" has public project "Community"
@@ -57,6 +64,7 @@ Feature: Explore Groups
     And I should see project "Internal" items
     And I should not see project "Enterprise" items
 
+  @javascript
   Scenario: I should see group issues for internal and public projects as user
     Given group "TestGroup" has internal project "Internal"
     Given group "TestGroup" has public project "Community"
@@ -66,6 +74,7 @@ Feature: Explore Groups
     And I should see project "Internal" items
     And I should not see project "Enterprise" items
 
+  @javascript
   Scenario: I should see group merge requests for internal and public projects as user
     Given group "TestGroup" has internal project "Internal"
     Given group "TestGroup" has public project "Community"
@@ -75,17 +84,20 @@ Feature: Explore Groups
     And I should see project "Internal" items
     And I should not see project "Enterprise" items
 
+  @javascript
   Scenario: I should see group with public project in public groups area
     Given group "TestGroup" has public project "Community"
     When I visit the public groups area
     Then I should see group "TestGroup"
 
+  @javascript
   Scenario: I should see group with public project in public groups area as user
     Given group "TestGroup" has public project "Community"
     When I sign in as a user
     And I visit the public groups area
     Then I should see group "TestGroup"
 
+  @javascript
   Scenario: I should see group with internal project in public groups area as user
     Given group "TestGroup" has internal project "Internal"
     When I sign in as a user
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 79e55a2f4f78fa538a1d53ef2afcd405ef9f56e6..99fcc59ba0416c9ed299c2243aa5a7a826b1a632 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -4,6 +4,10 @@ module API
 
     LOG_FILENAME = Rails.root.join("log", "api_json.log")
 
+    NO_SLASH_URL_PART_REGEX = %r{[^/]+}
+    PROJECT_ENDPOINT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze
+    COMMIT_ENDPOINT_REQUIREMENTS = PROJECT_ENDPOINT_REQUIREMENTS.merge(sha: NO_SLASH_URL_PART_REGEX).freeze
+
     use GrapeLogging::Middleware::RequestLogger,
         logger: Logger.new(LOG_FILENAME),
         formatter: Gitlab::GrapeLogging::Formatters::LogrageWithTimestamp.new,
@@ -96,9 +100,6 @@ module API
     helpers ::API::Helpers
     helpers ::API::Helpers::CommonHelpers
 
-    NO_SLASH_URL_PART_REGEX = %r{[^/]+}
-    PROJECT_ENDPOINT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze
-
     # Keep in alphabetical order
     mount ::API::AccessRequests
     mount ::API::AwardEmoji
diff --git a/lib/api/commits.rb b/lib/api/commits.rb
index 62565e2d5225a85bdc5f7424cbe31d2e1c67b6a0..2685dc27252ea5025f80568ed608a7e90c446266 100644
--- a/lib/api/commits.rb
+++ b/lib/api/commits.rb
@@ -4,8 +4,6 @@ module API
   class Commits < Grape::API
     include PaginationParams
 
-    COMMIT_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(sha: API::NO_SLASH_URL_PART_REGEX)
-
     before { authorize! :download_code, user_project }
 
     params do
@@ -85,7 +83,7 @@ module API
       params do
         requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
       end
-      get ':id/repository/commits/:sha', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+      get ':id/repository/commits/:sha', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
         commit = user_project.commit(params[:sha])
 
         not_found! 'Commit' unless commit
@@ -99,7 +97,7 @@ module API
       params do
         requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
       end
-      get ':id/repository/commits/:sha/diff', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+      get ':id/repository/commits/:sha/diff', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
         commit = user_project.commit(params[:sha])
 
         not_found! 'Commit' unless commit
@@ -115,7 +113,7 @@ module API
         use :pagination
         requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
       end
-      get ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+      get ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
         commit = user_project.commit(params[:sha])
 
         not_found! 'Commit' unless commit
@@ -132,7 +130,7 @@ module API
         requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag to be cherry picked'
         requires :branch, type: String, desc: 'The name of the branch'
       end
-      post ':id/repository/commits/:sha/cherry_pick', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+      post ':id/repository/commits/:sha/cherry_pick', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
         authorize! :push_code, user_project
 
         commit = user_project.commit(params[:sha])
@@ -169,7 +167,7 @@ module API
           requires :line_type, type: String, values: %w(new old), default: 'new', desc: 'The type of the line'
         end
       end
-      post ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+      post ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
         commit = user_project.commit(params[:sha])
         not_found! 'Commit' unless commit
 
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index ceee32267327f1c5557129b6118fb3d8f96e8f3d..7887b886c03676dfdba5ef0a99b21942635e2fb3 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -57,7 +57,7 @@ module API
 
       desc 'Get raw blob contents from the repository'
       params do
-        requires :sha, type: String, desc: 'The commit, branch name, or tag name'
+        requires :sha, type: String, desc: 'The commit hash'
       end
       get ':id/repository/blobs/:sha/raw' do
         assign_blob_vars!
@@ -67,7 +67,7 @@ module API
 
       desc 'Get a blob from the repository'
       params do
-        requires :sha, type: String, desc: 'The commit, branch name, or tag name'
+        requires :sha, type: String, desc: 'The commit hash'
       end
       get ':id/repository/blobs/:sha' do
         assign_blob_vars!
diff --git a/lib/api/v3/builds.rb b/lib/api/v3/builds.rb
index c189d486f5042b0640efcc530b232b8190b64a13..f493fd7c7ece7902f166060643cfcf98e96ce524 100644
--- a/lib/api/v3/builds.rb
+++ b/lib/api/v3/builds.rb
@@ -8,7 +8,7 @@ module API
       params do
         requires :id, type: String, desc: 'The ID of a project'
       end
-      resource :projects do
+      resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
         helpers do
           params :optional_scope do
             optional :scope, types: [String, Array[String]], desc: 'The scope of builds to show',
diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb
index bd5c6b34d1241c9605a62f6f1cdb129a34ef9eb7..ed206a6def00035081b4d2833dc095aa316f77e0 100644
--- a/lib/api/v3/commits.rb
+++ b/lib/api/v3/commits.rb
@@ -11,7 +11,7 @@ module API
       params do
         requires :id, type: String, desc: 'The ID of a project'
       end
-      resource :projects, requirements: { id: %r{[^/]+} } do
+      resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
         desc 'Get a project repository commits' do
           success ::API::Entities::Commit
         end
@@ -72,7 +72,7 @@ module API
         params do
           requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
         end
-        get ":id/repository/commits/:sha" do
+        get ":id/repository/commits/:sha", requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
           commit = user_project.commit(params[:sha])
 
           not_found! "Commit" unless commit
@@ -86,7 +86,7 @@ module API
         params do
           requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
         end
-        get ":id/repository/commits/:sha/diff" do
+        get ":id/repository/commits/:sha/diff", requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
           commit = user_project.commit(params[:sha])
 
           not_found! "Commit" unless commit
@@ -102,7 +102,7 @@ module API
           use :pagination
           requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
         end
-        get ':id/repository/commits/:sha/comments' do
+        get ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
           commit = user_project.commit(params[:sha])
 
           not_found! 'Commit' unless commit
@@ -119,7 +119,7 @@ module API
           requires :sha, type: String, desc: 'A commit sha to be cherry picked'
           requires :branch, type: String, desc: 'The name of the branch'
         end
-        post ':id/repository/commits/:sha/cherry_pick' do
+        post ':id/repository/commits/:sha/cherry_pick', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
           authorize! :push_code, user_project
 
           commit = user_project.commit(params[:sha])
@@ -156,7 +156,7 @@ module API
             requires :line_type, type: String, values: %w(new old), default: 'new', desc: 'The type of the line'
           end
         end
-        post ':id/repository/commits/:sha/comments' do
+        post ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
           commit = user_project.commit(params[:sha])
           not_found! 'Commit' unless commit
 
diff --git a/lib/api/v3/repositories.rb b/lib/api/v3/repositories.rb
index 41a7c6b83aeb4e6e470d8ea39ff820fd8f8a99b0..f9a47101e27d6f49e0f2cae8be5012fff51c0977 100644
--- a/lib/api/v3/repositories.rb
+++ b/lib/api/v3/repositories.rb
@@ -8,7 +8,7 @@ module API
       params do
         requires :id, type: String, desc: 'The ID of a project'
       end
-      resource :projects, requirements: { id: %r{[^/]+} } do
+      resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
         helpers do
           def handle_project_member_errors(errors)
             if errors[:project_access].any?
@@ -43,7 +43,7 @@ module API
           requires :sha, type: String, desc: 'The commit, branch name, or tag name'
           requires :filepath, type: String, desc: 'The path to the file to display'
         end
-        get [":id/repository/blobs/:sha", ":id/repository/commits/:sha/blob"] do
+        get [":id/repository/blobs/:sha", ":id/repository/commits/:sha/blob"], requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
           repo = user_project.repository
           commit = repo.commit(params[:sha])
           not_found! "Commit" unless commit
@@ -56,7 +56,7 @@ module API
         params do
           requires :sha, type: String, desc: 'The commit, branch name, or tag name'
         end
-        get ':id/repository/raw_blobs/:sha' do
+        get ':id/repository/raw_blobs/:sha', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
           repo = user_project.repository
           begin
             blob = Gitlab::Git::Blob.raw(repo, params[:sha])
diff --git a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
index 8e5c95f22871b6639e6525b2b6f465d8436a7cc7..380802258f5e8a18d1bb8da4011ba2737c227d37 100644
--- a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
+++ b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
@@ -81,6 +81,7 @@ module Gitlab
       def single_diff_rows(merge_request_diff)
         sha_attribute = Gitlab::Database::ShaAttribute.new
         commits = YAML.load(merge_request_diff.st_commits) rescue []
+        commits ||= []
 
         commit_rows = commits.map.with_index do |commit, index|
           commit_hash = commit.to_hash.with_indifferent_access.except(:parent_ids)
diff --git a/lib/gitlab/git/storage/circuit_breaker.rb b/lib/gitlab/git/storage/circuit_breaker.rb
index 1eaa2d83fb67ba2bda65eb210d21352a8d08f565..0456ad9a1f3a994d2659859e1ec253531fe0fcf6 100644
--- a/lib/gitlab/git/storage/circuit_breaker.rb
+++ b/lib/gitlab/git/storage/circuit_breaker.rb
@@ -2,15 +2,13 @@ module Gitlab
   module Git
     module Storage
       class CircuitBreaker
+        include CircuitBreakerSettings
+
         FailureInfo = Struct.new(:last_failure, :failure_count)
 
         attr_reader :storage,
                     :hostname,
-                    :storage_path,
-                    :failure_count_threshold,
-                    :failure_wait_time,
-                    :failure_reset_time,
-                    :storage_timeout
+                    :storage_path
 
         delegate :last_failure, :failure_count, to: :failure_info
 
@@ -18,7 +16,7 @@ module Gitlab
           pattern = "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}*"
 
           Gitlab::Git::Storage.redis.with do |redis|
-            all_storage_keys = redis.keys(pattern)
+            all_storage_keys = redis.scan_each(match: pattern).to_a
             redis.del(*all_storage_keys) unless all_storage_keys.empty?
           end
 
@@ -53,10 +51,6 @@ module Gitlab
 
           config = Gitlab.config.repositories.storages[@storage]
           @storage_path = config['path']
-          @failure_count_threshold = config['failure_count_threshold']
-          @failure_wait_time = config['failure_wait_time']
-          @failure_reset_time = config['failure_reset_time']
-          @storage_timeout = config['storage_timeout']
         end
 
         def perform
diff --git a/lib/gitlab/git/storage/circuit_breaker_settings.rb b/lib/gitlab/git/storage/circuit_breaker_settings.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d2313fe7c1ba946d5f799670a93d3e71a926f7d5
--- /dev/null
+++ b/lib/gitlab/git/storage/circuit_breaker_settings.rb
@@ -0,0 +1,29 @@
+module Gitlab
+  module Git
+    module Storage
+      module CircuitBreakerSettings
+        def failure_count_threshold
+          application_settings.circuitbreaker_failure_count_threshold
+        end
+
+        def failure_wait_time
+          application_settings.circuitbreaker_failure_wait_time
+        end
+
+        def failure_reset_time
+          application_settings.circuitbreaker_failure_reset_time
+        end
+
+        def storage_timeout
+          application_settings.circuitbreaker_storage_timeout
+        end
+
+        private
+
+        def application_settings
+          Gitlab::CurrentSettings.current_application_settings
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/git/storage/health.rb b/lib/gitlab/git/storage/health.rb
index 1564e94b7f7afb7db67e8d4634f4d7a76443a474..7049772fe3bae8e66114cd7ff7691c1fb8b565f0 100644
--- a/lib/gitlab/git/storage/health.rb
+++ b/lib/gitlab/git/storage/health.rb
@@ -23,26 +23,36 @@ module Gitlab
           end
         end
 
-        def self.all_keys_for_storages(storage_names, redis)
+        private_class_method def self.all_keys_for_storages(storage_names, redis)
           keys_per_storage = {}
 
           redis.pipelined do
             storage_names.each do |storage_name|
               pattern = pattern_for_storage(storage_name)
+              matched_keys = redis.scan_each(match: pattern)
 
-              keys_per_storage[storage_name] = redis.keys(pattern)
+              keys_per_storage[storage_name] = matched_keys
             end
           end
 
-          keys_per_storage
+          # We need to make sure each lazy-loaded `Enumerator` for matched keys
+          # is loaded into an array.
+          #
+          # Otherwise it would be loaded in the second `Redis#pipelined` block
+          # within `.load_for_keys`. In this pipelined call, the active
+          # Redis-client changes again, so the values would not be available
+          # until the end of that pipelined-block.
+          keys_per_storage.each do |storage_name, key_future|
+            keys_per_storage[storage_name] = key_future.to_a
+          end
         end
 
-        def self.load_for_keys(keys_per_storage, redis)
+        private_class_method def self.load_for_keys(keys_per_storage, redis)
           info_for_keys = {}
 
           redis.pipelined do
             keys_per_storage.each do |storage_name, keys_future|
-              info_for_storage = keys_future.value.map do |key|
+              info_for_storage = keys_future.map do |key|
                 { name: key, failure_count: redis.hget(key, :failure_count) }
               end
 
diff --git a/lib/gitlab/git/storage/null_circuit_breaker.rb b/lib/gitlab/git/storage/null_circuit_breaker.rb
index 297c043d054c8cb91e33b5d35ccfbcb4381d5c2a..60c6791a7e4f18c76e6984b3010a8d99d11abb95 100644
--- a/lib/gitlab/git/storage/null_circuit_breaker.rb
+++ b/lib/gitlab/git/storage/null_circuit_breaker.rb
@@ -2,15 +2,14 @@ module Gitlab
   module Git
     module Storage
       class NullCircuitBreaker
+        include CircuitBreakerSettings
+
         # These will have actual values
         attr_reader :storage,
                     :hostname
 
         # These will always have nil values
-        attr_reader :storage_path,
-                    :failure_wait_time,
-                    :failure_reset_time,
-                    :storage_timeout
+        attr_reader :storage_path
 
         def initialize(storage, hostname, error: nil)
           @storage = storage
@@ -26,16 +25,12 @@ module Gitlab
           !!@error
         end
 
-        def failure_count_threshold
-          1
-        end
-
         def last_failure
           circuit_broken? ? Time.now : nil
         end
 
         def failure_count
-          circuit_broken? ? 1 : 0
+          circuit_broken? ? failure_count_threshold : 0
         end
 
         def failure_info
diff --git a/lib/gitlab/group_hierarchy.rb b/lib/gitlab/group_hierarchy.rb
index 635f52131f94326b33859131c827bb13367b695a..42ded7c286f666359f517683a3f73bffb90999ef 100644
--- a/lib/gitlab/group_hierarchy.rb
+++ b/lib/gitlab/group_hierarchy.rb
@@ -17,12 +17,32 @@ module Gitlab
       @model = ancestors_base.model
     end
 
+    # Returns the set of descendants of a given relation, but excluding the given
+    # relation
+    def descendants
+      base_and_descendants.where.not(id: descendants_base.select(:id))
+    end
+
+    # Returns the set of ancestors of a given relation, but excluding the given
+    # relation
+    #
+    # Passing an `upto` will stop the recursion once the specified parent_id is
+    # reached. So all ancestors *lower* than the specified ancestor will be
+    # included.
+    def ancestors(upto: nil)
+      base_and_ancestors(upto: upto).where.not(id: ancestors_base.select(:id))
+    end
+
     # Returns a relation that includes the ancestors_base set of groups
     # and all their ancestors (recursively).
-    def base_and_ancestors
+    #
+    # Passing an `upto` will stop the recursion once the specified parent_id is
+    # reached. So all ancestors *lower* than the specified acestor will be
+    # included.
+    def base_and_ancestors(upto: nil)
       return ancestors_base unless Group.supports_nested_groups?
 
-      read_only(base_and_ancestors_cte.apply_to(model.all))
+      read_only(base_and_ancestors_cte(upto).apply_to(model.all))
     end
 
     # Returns a relation that includes the descendants_base set of groups
@@ -78,17 +98,19 @@ module Gitlab
 
     private
 
-    def base_and_ancestors_cte
+    def base_and_ancestors_cte(stop_id = nil)
       cte = SQL::RecursiveCTE.new(:base_and_ancestors)
 
       cte << ancestors_base.except(:order)
 
       # Recursively get all the ancestors of the base set.
-      cte << model
+      parent_query = model
         .from([groups_table, cte.table])
         .where(groups_table[:id].eq(cte.table[:parent_id]))
         .except(:order)
+      parent_query = parent_query.where(cte.table[:parent_id].not_eq(stop_id)) if stop_id
 
+      cte << parent_query
       cte
     end
 
diff --git a/lib/gitlab/multi_collection_paginator.rb b/lib/gitlab/multi_collection_paginator.rb
new file mode 100644
index 0000000000000000000000000000000000000000..eb3c9002710f938923c580ecebaff0ee5916c8de
--- /dev/null
+++ b/lib/gitlab/multi_collection_paginator.rb
@@ -0,0 +1,61 @@
+module Gitlab
+  class MultiCollectionPaginator
+    attr_reader :first_collection, :second_collection, :per_page
+
+    def initialize(*collections, per_page: nil)
+      raise ArgumentError.new('Only 2 collections are supported') if collections.size != 2
+
+      @per_page = per_page || Kaminari.config.default_per_page
+      @first_collection, @second_collection = collections
+    end
+
+    def paginate(page)
+      page = page.to_i
+      paginated_first_collection(page) + paginated_second_collection(page)
+    end
+
+    def total_count
+      @total_count ||= first_collection.size + second_collection.size
+    end
+
+    private
+
+    def paginated_first_collection(page)
+      @first_collection_pages ||= Hash.new do |hash, page|
+        hash[page] = first_collection.page(page).per(per_page)
+      end
+
+      @first_collection_pages[page]
+    end
+
+    def paginated_second_collection(page)
+      @second_collection_pages ||= Hash.new do |hash, page|
+        second_collection_page = page - first_collection_page_count
+
+        offset = if second_collection_page < 1 || first_collection_page_count.zero?
+                   0
+                 else
+                   per_page - first_collection_last_page_size
+                 end
+        hash[page] = second_collection.page(second_collection_page)
+                       .per(per_page - paginated_first_collection(page).size)
+                       .padding(offset)
+      end
+
+      @second_collection_pages[page]
+    end
+
+    def first_collection_page_count
+      return @first_collection_page_count if defined?(@first_collection_page_count)
+
+      first_collection_page = paginated_first_collection(0)
+      @first_collection_page_count = first_collection_page.total_pages
+    end
+
+    def first_collection_last_page_size
+      return @first_collection_last_page_size if defined?(@first_collection_last_page_size)
+
+      @first_collection_last_page_size = paginated_first_collection(first_collection_page_count).count
+    end
+  end
+end
diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb
index 7c02c9c5c485f700c122773bf0d8d1ded0b4bcdc..22f8dd669d037c66c3e0671d27cd6d9fd1f9ad19 100644
--- a/lib/gitlab/path_regex.rb
+++ b/lib/gitlab/path_regex.rb
@@ -26,7 +26,6 @@ module Gitlab
       apple-touch-icon.png
       assets
       autocomplete
-      boards
       ci
       dashboard
       deploy.html
@@ -129,7 +128,6 @@ module Gitlab
       notification_setting
       pipeline_quota
       projects
-      subgroups
     ].freeze
 
     ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES
diff --git a/lib/gitlab/sql/union.rb b/lib/gitlab/sql/union.rb
index f30c771837ab0ffef48391bc6cc00872a8cf4a62..c99b262f1caa9b6b31a56bde7bcd3c84879555af 100644
--- a/lib/gitlab/sql/union.rb
+++ b/lib/gitlab/sql/union.rb
@@ -26,7 +26,11 @@ module Gitlab
           @relations.map { |rel| rel.reorder(nil).to_sql }.reject(&:blank?)
         end
 
-        fragments.join("\n#{union_keyword}\n")
+        if fragments.any?
+          fragments.join("\n#{union_keyword}\n")
+        else
+          'NULL'
+        end
       end
 
       def union_keyword
diff --git a/lib/gitlab/utils/merge_hash.rb b/lib/gitlab/utils/merge_hash.rb
new file mode 100644
index 0000000000000000000000000000000000000000..385141d44d099b14d5f65de5276d385aabc3ed8d
--- /dev/null
+++ b/lib/gitlab/utils/merge_hash.rb
@@ -0,0 +1,117 @@
+module Gitlab
+  module Utils
+    module MergeHash
+      extend self
+      # Deep merges an array of hashes
+      #
+      # [{ hello: ["world"] },
+      #  { hello: "Everyone" },
+      #  { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] } },
+      #   "Goodbye", "Hallo"]
+      # =>  [
+      #       {
+      #         hello:
+      #           [
+      #             "world",
+      #             "Everyone",
+      #             { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] }
+      #           ]
+      #       },
+      #       "Goodbye"
+      #     ]
+      def merge(elements)
+        merged, *other_elements = elements
+
+        other_elements.each do |element|
+          merged = merge_hash_tree(merged, element)
+        end
+
+        merged
+      end
+
+      # This extracts all keys and values from a hash into an array
+      #
+      # { hello: "world", this: { crushes: ["an entire", "hash"] } }
+      # => [:hello, "world", :this, :crushes, "an entire", "hash"]
+      def crush(array_or_hash)
+        if array_or_hash.is_a?(Array)
+          crush_array(array_or_hash)
+        else
+          crush_hash(array_or_hash)
+        end
+      end
+
+      private
+
+      def merge_hash_into_array(array, new_hash)
+        crushed_new_hash = crush_hash(new_hash)
+        # Merge the hash into an existing element of the array if there is overlap
+        if mergeable_index = array.index { |element| crushable?(element) && (crush(element) & crushed_new_hash).any? }
+          array[mergeable_index] = merge_hash_tree(array[mergeable_index], new_hash)
+        else
+          array << new_hash
+        end
+
+        array
+      end
+
+      def merge_hash_tree(first_element, second_element)
+        # If one of the elements is an object, and the other is a Hash or Array
+        # we can check if the object is already included. If so, we don't need to do anything
+        #
+        # Handled cases
+        # [Hash, Object], [Array, Object]
+        if crushable?(first_element) && crush(first_element).include?(second_element)
+          first_element
+        elsif crushable?(second_element) && crush(second_element).include?(first_element)
+          second_element
+        # When the first is an array, we need to go over every element to see if
+        # we can merge deeper. If no match is found, we add the element to the array
+        #
+        # Handled cases:
+        # [Array, Hash]
+        elsif first_element.is_a?(Array) && second_element.is_a?(Hash)
+          merge_hash_into_array(first_element, second_element)
+        elsif first_element.is_a?(Hash) && second_element.is_a?(Array)
+          merge_hash_into_array(second_element, first_element)
+        # If both of them are hashes, we can deep_merge with the same logic
+        #
+        # Handled cases:
+        # [Hash, Hash]
+        elsif first_element.is_a?(Hash) && second_element.is_a?(Hash)
+          first_element.deep_merge(second_element) { |key, first, second| merge_hash_tree(first, second) }
+        # If both elements are arrays, we try to merge each element separatly
+        #
+        # Handled cases
+        # [Array, Array]
+        elsif first_element.is_a?(Array) && second_element.is_a?(Array)
+          first_element.map { |child_element| merge_hash_tree(child_element, second_element) }
+        # If one or both elements are a GroupDescendant, we wrap create an array
+        # combining them.
+        #
+        # Handled cases:
+        # [Object, Object], [Array, Array]
+        else
+          (Array.wrap(first_element) + Array.wrap(second_element)).uniq
+        end
+      end
+
+      def crushable?(element)
+        element.is_a?(Hash) || element.is_a?(Array)
+      end
+
+      def crush_hash(hash)
+        hash.flat_map do |key, value|
+          crushed_value = crushable?(value) ? crush(value) : value
+          Array.wrap(key) + Array.wrap(crushed_value)
+        end
+      end
+
+      def crush_array(array)
+        array.flat_map do |element|
+          crushable?(element) ? crush(element) : element
+        end
+      end
+    end
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c73e582e60849e4ea1b06bf89a103ad36671126f..1f356a231b05dd58ecd13dccd6a97345bb3d3d01 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -8,8 +8,8 @@ msgid ""
 msgstr ""
 "Project-Id-Version: gitlab 1.0.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-10-06 18:33+0200\n"
-"PO-Revision-Date: 2017-10-06 18:33+0200\n"
+"POT-Creation-Date: 2017-10-10 17:50+0200\n"
+"PO-Revision-Date: 2017-10-10 17:50+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
 "Language: \n"
@@ -109,6 +109,9 @@ msgstr ""
 msgid "All"
 msgstr ""
 
+msgid "An error occurred. Please try again."
+msgstr ""
+
 msgid "Appearance"
 msgstr ""
 
@@ -375,6 +378,9 @@ msgstr ""
 msgid "Clone repository"
 msgstr ""
 
+msgid "Cluster"
+msgstr ""
+
 msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
 msgstr ""
 
@@ -420,13 +426,10 @@ msgstr ""
 msgid "ClusterIntegration|Google Container Engine project"
 msgstr ""
 
-msgid "ClusterIntegration|Google Container Engine"
-msgstr ""
-
 msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
 msgstr ""
 
-msgid "ClusterIntegration|See machine types"
+msgid "ClusterIntegration|Machine type"
 msgstr ""
 
 msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
@@ -438,9 +441,15 @@ msgstr ""
 msgid "ClusterIntegration|Number of nodes"
 msgstr ""
 
+msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgstr ""
+
 msgid "ClusterIntegration|Project namespace (optional, unique)"
 msgstr ""
 
+msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgstr ""
+
 msgid "ClusterIntegration|Remove cluster integration"
 msgstr ""
 
@@ -450,7 +459,10 @@ msgstr ""
 msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
 msgstr ""
 
-msgid "ClusterIntegration|Save changes"
+msgid "ClusterIntegration|Save"
+msgstr ""
+
+msgid "ClusterIntegration|See machine types"
 msgstr ""
 
 msgid "ClusterIntegration|See your projects"
@@ -462,18 +474,12 @@ msgstr ""
 msgid "ClusterIntegration|Something went wrong on our end."
 msgstr ""
 
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine."
-msgstr ""
-
-msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine"
 msgstr ""
 
 msgid "ClusterIntegration|Toggle Cluster"
 msgstr ""
 
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
-msgstr ""
-
 msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
 msgstr ""
 
@@ -691,6 +697,9 @@ msgstr ""
 msgid "Discard changes"
 msgstr ""
 
+msgid "Dismiss Cycle Analytics introduction box"
+msgstr ""
+
 msgid "Don't show again"
 msgstr ""
 
@@ -760,6 +769,9 @@ msgstr ""
 msgid "Explore projects"
 msgstr ""
 
+msgid "Explore public groups"
+msgstr ""
+
 msgid "Failed to change the owner"
 msgstr ""
 
@@ -846,6 +858,51 @@ msgstr ""
 msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}"
 msgstr ""
 
+msgid "GroupsEmptyState|A group is a collection of several projects."
+msgstr ""
+
+msgid "GroupsEmptyState|If you organize your projects under a group, it works like a folder."
+msgstr ""
+
+msgid "GroupsEmptyState|No groups found"
+msgstr ""
+
+msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
+msgstr ""
+
+msgid "GroupsTreeRole|as"
+msgstr ""
+
+msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
+msgstr ""
+
+msgid "GroupsTree|Create a project in this group."
+msgstr ""
+
+msgid "GroupsTree|Create a subgroup in this group."
+msgstr ""
+
+msgid "GroupsTree|Edit group"
+msgstr ""
+
+msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
+msgstr ""
+
+msgid "GroupsTree|Filter by name..."
+msgstr ""
+
+msgid "GroupsTree|Leave this group"
+msgstr ""
+
+msgid "GroupsTree|Loading groups"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups matched your search"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups or projects matched your search"
+msgstr ""
+
 msgid "Health Check"
 msgstr ""
 
@@ -876,6 +933,12 @@ msgstr ""
 msgid "Install a Runner compatible with GitLab CI"
 msgstr ""
 
+msgid "Internal - The group and any internal projects can be viewed by any logged in user."
+msgstr ""
+
+msgid "Internal - The project can be accessed by any logged in user."
+msgstr ""
+
 msgid "Interval Pattern"
 msgstr ""
 
@@ -935,6 +998,9 @@ msgstr ""
 msgid "Learn more in the|pipeline schedules documentation"
 msgstr ""
 
+msgid "Leave"
+msgstr ""
+
 msgid "Leave group"
 msgstr ""
 
@@ -946,6 +1012,15 @@ msgid_plural "Limited to showing %d events at most"
 msgstr[0] ""
 msgstr[1] ""
 
+msgid "Lock"
+msgstr ""
+
+msgid "Locked"
+msgstr ""
+
+msgid "Login"
+msgstr ""
+
 msgid "Median"
 msgstr ""
 
@@ -973,6 +1048,9 @@ msgstr ""
 msgid "More information is available|here"
 msgstr ""
 
+msgid "New Cluster"
+msgstr ""
+
 msgid "New Issue"
 msgid_plural "New Issues"
 msgstr[0] ""
@@ -990,18 +1068,27 @@ msgstr ""
 msgid "New file"
 msgstr ""
 
+msgid "New group"
+msgstr ""
+
 msgid "New issue"
 msgstr ""
 
 msgid "New merge request"
 msgstr ""
 
+msgid "New project"
+msgstr ""
+
 msgid "New schedule"
 msgstr ""
 
 msgid "New snippet"
 msgstr ""
 
+msgid "New subgroup"
+msgstr ""
+
 msgid "New tag"
 msgstr ""
 
@@ -1080,6 +1167,9 @@ msgstr ""
 msgid "OfSearchInADropdown|Filter"
 msgstr ""
 
+msgid "Only project members can comment."
+msgstr ""
+
 msgid "OpenedNDaysAgo|Opened"
 msgstr ""
 
@@ -1110,6 +1200,9 @@ msgstr ""
 msgid "Password"
 msgstr ""
 
+msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgstr ""
+
 msgid "Pipeline"
 msgstr ""
 
@@ -1209,9 +1302,51 @@ msgstr ""
 msgid "Preferences"
 msgstr ""
 
+msgid "Private - Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Private - The group and its projects can only be viewed by members."
+msgstr ""
+
 msgid "Profile"
 msgstr ""
 
+msgid "Profiles|Account scheduled for removal."
+msgstr ""
+
+msgid "Profiles|Delete Account"
+msgstr ""
+
+msgid "Profiles|Delete account"
+msgstr ""
+
+msgid "Profiles|Delete your account?"
+msgstr ""
+
+msgid "Profiles|Deleting an account has the following effects:"
+msgstr ""
+
+msgid "Profiles|Invalid password"
+msgstr ""
+
+msgid "Profiles|Invalid username"
+msgstr ""
+
+msgid "Profiles|Type your %{confirmationValue} to confirm:"
+msgstr ""
+
+msgid "Profiles|You don't have access to delete this user."
+msgstr ""
+
+msgid "Profiles|You must transfer ownership or delete these groups before you can delete your account."
+msgstr ""
+
+msgid "Profiles|Your account is currently an owner in these groups:"
+msgstr ""
+
+msgid "Profiles|your account"
+msgstr ""
+
 msgid "Project '%{project_name}' queued for deletion."
 msgstr ""
 
@@ -1266,6 +1401,9 @@ msgstr ""
 msgid "ProjectNetworkGraph|Graph"
 msgstr ""
 
+msgid "Projects"
+msgstr ""
+
 msgid "ProjectsDropdown|Frequently visited"
 msgstr ""
 
@@ -1287,6 +1425,12 @@ msgstr ""
 msgid "ProjectsDropdown|This feature requires browser localStorage support"
 msgstr ""
 
+msgid "Public - The group and any public projects can be viewed without any authentication."
+msgstr ""
+
+msgid "Public - The project can be accessed without any authentication."
+msgstr ""
+
 msgid "Push events"
 msgstr ""
 
@@ -1415,12 +1559,18 @@ msgstr ""
 msgid "Something went wrong on our end."
 msgstr ""
 
+msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgstr ""
+
 msgid "Something went wrong while fetching the projects."
 msgstr ""
 
 msgid "Something went wrong while fetching the registry list."
 msgstr ""
 
+msgid "Sort by"
+msgstr ""
+
 msgid "SortOptions|Access level, ascending"
 msgstr ""
 
@@ -1529,6 +1679,9 @@ msgstr ""
 msgid "Start the Runner!"
 msgstr ""
 
+msgid "Subgroups"
+msgstr ""
+
 msgid "Switch branch/tag"
 msgstr ""
 
@@ -1600,12 +1753,24 @@ msgstr ""
 msgid "There are problems accessing Git storage: "
 msgstr ""
 
+msgid "This is a confidential issue."
+msgstr ""
+
 msgid "This is the author's first Merge Request to this project."
 msgstr ""
 
+msgid "This issue is confidential and locked."
+msgstr ""
+
+msgid "This issue is locked."
+msgstr ""
+
 msgid "This means you can not push code until you create an empty repository or import existing one."
 msgstr ""
 
+msgid "This merge request is locked."
+msgstr ""
+
 msgid "Time before an issue gets scheduled"
 msgstr ""
 
@@ -1760,6 +1925,12 @@ msgstr ""
 msgid "Total test time for all commits/merges"
 msgstr ""
 
+msgid "Unlock"
+msgstr ""
+
+msgid "Unlocked"
+msgstr ""
+
 msgid "Unstar"
 msgstr ""
 
@@ -1922,9 +2093,15 @@ msgstr ""
 msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
 msgstr ""
 
+msgid "You are on a read-only GitLab instance."
+msgstr ""
+
 msgid "You can only add files when you are on a branch"
 msgstr ""
 
+msgid "You cannot write to this read-only GitLab instance."
+msgstr ""
+
 msgid "You have reached your project limit"
 msgstr ""
 
@@ -1955,6 +2132,12 @@ msgstr ""
 msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
 msgstr ""
 
+msgid "Your comment will not be visible to the public."
+msgstr ""
+
+msgid "Your groups"
+msgstr ""
+
 msgid "Your name"
 msgstr ""
 
@@ -1977,5 +2160,11 @@ msgid_plural "parents"
 msgstr[0] ""
 msgstr[1] ""
 
+msgid "password"
+msgstr ""
+
 msgid "personal access token"
 msgstr ""
+
+msgid "username"
+msgstr ""
diff --git a/package.json b/package.json
index b670415deb4045cccd67bcd33afe47ed9517f0a2..620b39766f4c4180027af18106eada49ce6a165b 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
     "jszip": "^3.1.3",
     "jszip-utils": "^0.0.2",
     "marked": "^0.3.6",
-    "monaco-editor": "0.8.3",
+    "monaco-editor": "0.10.0",
     "mousetrap": "^1.4.6",
     "name-all-modules-plugin": "^1.0.1",
     "pikaday": "^1.5.1",
diff --git a/qa/qa.rb b/qa/qa.rb
index eb6f922d0d3816a97a0494b42be1024c93886349..59d9dd131c2adbecd09428d533a9189ddea982bd 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -18,6 +18,7 @@ module QA
     # Support files
     #
     autoload :Actable, 'qa/scenario/actable'
+    autoload :Entrypoint, 'qa/scenario/entrypoint'
     autoload :Template, 'qa/scenario/template'
 
     ##
@@ -25,6 +26,10 @@ module QA
     #
     module Test
       autoload :Instance, 'qa/scenario/test/instance'
+
+      module Integration
+        autoload :Mattermost, 'qa/scenario/test/integration/mattermost'
+      end
     end
 
     ##
diff --git a/qa/qa/scenario/entrypoint.rb b/qa/qa/scenario/entrypoint.rb
new file mode 100644
index 0000000000000000000000000000000000000000..33cb2696f8f391a3ac0b82387829378af42a7bc2
--- /dev/null
+++ b/qa/qa/scenario/entrypoint.rb
@@ -0,0 +1,36 @@
+module QA
+  module Scenario
+    ##
+    # Base class for running the suite against any GitLab instance,
+    # including staging and on-premises installation.
+    #
+    class Entrypoint < Template
+      def self.tags(*tags)
+        @tags = tags
+      end
+
+      def self.get_tags
+        @tags
+      end
+
+      def perform(address, *files)
+        Specs::Config.perform do |specs|
+          specs.address = address
+        end
+
+        ##
+        # Perform before hooks, which are different for CE and EE
+        #
+        Runtime::Release.perform_before_hooks
+
+        Specs::Runner.perform do |specs|
+          specs.rspec(
+            tty: true,
+            tags: self.class.get_tags,
+            files: files.any? ? files : 'qa/specs/features'
+          )
+        end
+      end
+    end
+  end
+end
diff --git a/qa/qa/scenario/test/instance.rb b/qa/qa/scenario/test/instance.rb
index 689292bc60b8cb5277d4d4bfd474edb8f1587b8d..e2a1f6bf2bd342d1a2dd12f889066b1be6f3f3f9 100644
--- a/qa/qa/scenario/test/instance.rb
+++ b/qa/qa/scenario/test/instance.rb
@@ -5,21 +5,8 @@ module QA
       # Run test suite against any GitLab instance,
       # including staging and on-premises installation.
       #
-      class Instance < Scenario::Template
-        def perform(address, *files)
-          Specs::Config.perform do |specs|
-            specs.address = address
-          end
-
-          ##
-          # Perform before hooks, which are different for CE and EE
-          #
-          Runtime::Release.perform_before_hooks
-
-          Specs::Runner.perform do |specs|
-            specs.rspec('--tty', files.any? ? files : 'qa/specs/features')
-          end
-        end
+      class Instance < Entrypoint
+        tags :core
       end
     end
   end
diff --git a/qa/qa/scenario/test/integration/mattermost.rb b/qa/qa/scenario/test/integration/mattermost.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4732f2b635b946c117da7bd6b7b3e1c45ce0713e
--- /dev/null
+++ b/qa/qa/scenario/test/integration/mattermost.rb
@@ -0,0 +1,15 @@
+module QA
+  module Scenario
+    module Test
+      module Integration
+        ##
+        # Run test suite against any GitLab instance where mattermost is enabled,
+        # including staging and on-premises installation.
+        #
+        class Mattermost < Scenario::Entrypoint
+          tags :core, :mattermost
+        end
+      end
+    end
+  end
+end
diff --git a/qa/qa/specs/features/login/standard_spec.rb b/qa/qa/specs/features/login/standard_spec.rb
index 8e1ae6efa47da70dc528e7fa4afc213b18c1e7ca..ba19ce17ee51fe53098f1c5c4bd8008f4c1f8c05 100644
--- a/qa/qa/specs/features/login/standard_spec.rb
+++ b/qa/qa/specs/features/login/standard_spec.rb
@@ -1,5 +1,5 @@
 module QA
-  feature 'standard root login' do
+  feature 'standard root login', :core do
     scenario 'user logs in using credentials' do
       Page::Main::Entry.act { sign_in_using_credentials }
 
diff --git a/qa/qa/specs/features/mattermost/group_create_spec.rb b/qa/qa/specs/features/mattermost/group_create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c4afd83c8e4d524ad640ea08b12d1ed7817d85c6
--- /dev/null
+++ b/qa/qa/specs/features/mattermost/group_create_spec.rb
@@ -0,0 +1,16 @@
+module QA
+  feature 'create a new group', :mattermost do
+    scenario 'creating a group with a mattermost team' do
+      Page::Main::Entry.act { sign_in_using_credentials }
+      Page::Main::Menu.act { go_to_groups }
+
+      Page::Dashboard::Groups.perform do |page|
+        page.go_to_new_group
+
+        expect(page).to have_content(
+          /Create a Mattermost team for this group/
+        )
+      end
+    end
+  end
+end
diff --git a/qa/qa/specs/features/project/create_spec.rb b/qa/qa/specs/features/project/create_spec.rb
index 610492b9717e79d9f1bdb981a0c43b513d3cd355..27eb22f15a6b74cb90ef1184dbf3b66c1dd4e29b 100644
--- a/qa/qa/specs/features/project/create_spec.rb
+++ b/qa/qa/specs/features/project/create_spec.rb
@@ -1,5 +1,5 @@
 module QA
-  feature 'create a new project' do
+  feature 'create a new project', :core do
     scenario 'user creates a new project' do
       Page::Main::Entry.act { sign_in_using_credentials }
 
diff --git a/qa/qa/specs/features/repository/clone_spec.rb b/qa/qa/specs/features/repository/clone_spec.rb
index 521bd955857e8c2a14ab5a6134c9d5e82b92cada..3571173783da056ea21f7fbda816ced4eaae231c 100644
--- a/qa/qa/specs/features/repository/clone_spec.rb
+++ b/qa/qa/specs/features/repository/clone_spec.rb
@@ -1,5 +1,5 @@
 module QA
-  feature 'clone code from the repository' do
+  feature 'clone code from the repository', :core do
     context 'with regular account over http' do
       given(:location) do
         Page::Project::Show.act do
diff --git a/qa/qa/specs/features/repository/push_spec.rb b/qa/qa/specs/features/repository/push_spec.rb
index 5fe45d63d37c8a83822827352be755aaa29164a1..0e691fb0d75046505bfbbbf665aa3b28172edd5f 100644
--- a/qa/qa/specs/features/repository/push_spec.rb
+++ b/qa/qa/specs/features/repository/push_spec.rb
@@ -1,7 +1,7 @@
 module QA
-  feature 'push code to repository' do
+  feature 'push code to repository', :core do
     context 'with regular account over http' do
-      scenario 'user pushes code to the repository' do
+      scenario 'user pushes code to the repository'  do
         Page::Main::Entry.act { sign_in_using_credentials }
 
         Scenario::Gitlab::Project::Create.perform do |scenario|
diff --git a/qa/qa/specs/runner.rb b/qa/qa/specs/runner.rb
index 83ae15d099522d9cde8c50d69fa7bbc3dd5b7f67..2aa18d5d3a10fcd1c4d1d7162aa0bd474ef6c637 100644
--- a/qa/qa/specs/runner.rb
+++ b/qa/qa/specs/runner.rb
@@ -5,7 +5,14 @@ module QA
     class Runner
       include Scenario::Actable
 
-      def rspec(*args)
+      def rspec(tty: false, tags: [], files: ['qa/specs/features'])
+        args = []
+        args << '--tty' if tty
+        tags.to_a.each do |tag|
+          args << ['-t', tag.to_s]
+        end
+        args << files
+
         RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status|
           abort if status.nonzero?
         end
diff --git a/spec/bin/changelog_spec.rb b/spec/bin/changelog_spec.rb
index 6d8b9865dcbb66e589a4416d4bf072b3d85cf656..fc1bf67d7b969d1e1e077f859378a3d68dd0dd95 100644
--- a/spec/bin/changelog_spec.rb
+++ b/spec/bin/changelog_spec.rb
@@ -84,7 +84,7 @@ describe 'bin/changelog' do
           expect do
             expect do
               expect { described_class.read_type }.to raise_error(SystemExit)
-            end.to output("Invalid category index, please select an index between 1 and 7\n").to_stderr
+            end.to output("Invalid category index, please select an index between 1 and 8\n").to_stderr
           end.to output.to_stdout
         end
       end
diff --git a/spec/controllers/concerns/group_tree_spec.rb b/spec/controllers/concerns/group_tree_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ba84fbf8564cf4784cda8c265bea6f4dae5b0db7
--- /dev/null
+++ b/spec/controllers/concerns/group_tree_spec.rb
@@ -0,0 +1,89 @@
+require 'spec_helper'
+
+describe GroupTree do
+  let(:group) { create(:group, :public) }
+  let(:user) { create(:user) }
+
+  controller(ApplicationController) do
+    # `described_class` is not available in this context
+    include GroupTree # rubocop:disable RSpec/DescribedClass
+
+    def index
+      render_group_tree GroupsFinder.new(current_user).execute
+    end
+  end
+
+  before do
+    group.add_owner(user)
+    sign_in(user)
+  end
+
+  describe 'GET #index' do
+    it 'filters groups' do
+      other_group = create(:group, name: 'filter')
+      other_group.add_owner(user)
+
+      get :index, filter: 'filt', format: :json
+
+      expect(assigns(:groups)).to contain_exactly(other_group)
+    end
+
+    context 'for subgroups', :nested_groups do
+      it 'only renders root groups when no parent was given' do
+        create(:group, :public, parent: group)
+
+        get :index, format: :json
+
+        expect(assigns(:groups)).to contain_exactly(group)
+      end
+
+      it 'contains only the subgroup when a parent was given' do
+        subgroup = create(:group, :public, parent: group)
+
+        get :index, parent_id: group.id, format: :json
+
+        expect(assigns(:groups)).to contain_exactly(subgroup)
+      end
+
+      it 'allows filtering for subgroups and includes the parents for rendering' do
+        subgroup = create(:group, :public, parent: group, name: 'filter')
+
+        get :index, filter: 'filt', format: :json
+
+        expect(assigns(:groups)).to contain_exactly(group, subgroup)
+      end
+
+      it 'does not include groups the user does not have access to' do
+        parent = create(:group, :private)
+        subgroup = create(:group, :private, parent: parent, name: 'filter')
+        subgroup.add_developer(user)
+        _other_subgroup = create(:group, :private, parent: parent, name: 'filte')
+
+        get :index, filter: 'filt', format: :json
+
+        expect(assigns(:groups)).to contain_exactly(parent, subgroup)
+      end
+    end
+
+    context 'json content' do
+      it 'shows groups as json' do
+        get :index, format: :json
+
+        expect(json_response.first['id']).to eq(group.id)
+      end
+
+      context 'nested groups', :nested_groups do
+        it 'expands the tree when filtering' do
+          subgroup = create(:group, :public, parent: group, name: 'filter')
+
+          get :index, filter: 'filt', format: :json
+
+          children_response = json_response.first['children']
+
+          expect(json_response.first['id']).to eq(group.id)
+          expect(children_response.first['id']).to eq(subgroup.id)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/controllers/dashboard/groups_controller_spec.rb b/spec/controllers/dashboard/groups_controller_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fb9d3efbac011042b503668b5afa5ae33f6c206e
--- /dev/null
+++ b/spec/controllers/dashboard/groups_controller_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe Dashboard::GroupsController do
+  let(:user) { create(:user) }
+
+  before do
+    sign_in(user)
+  end
+
+  it 'renders group trees' do
+    expect(described_class).to include(GroupTree)
+  end
+
+  it 'only includes projects the user is a member of' do
+    member_of_group = create(:group)
+    member_of_group.add_developer(user)
+    create(:group, :public)
+
+    get :index
+
+    expect(assigns(:groups)).to contain_exactly(member_of_group)
+  end
+end
diff --git a/spec/controllers/explore/groups_controller_spec.rb b/spec/controllers/explore/groups_controller_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9e0ad9ea86f9d9897d45fb31d96a9b27c4a1dbd5
--- /dev/null
+++ b/spec/controllers/explore/groups_controller_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe Explore::GroupsController do
+  let(:user) { create(:user) }
+
+  before do
+    sign_in(user)
+  end
+
+  it 'renders group trees' do
+    expect(described_class).to include(GroupTree)
+  end
+
+  it 'includes public projects' do
+    member_of_group = create(:group)
+    member_of_group.add_developer(user)
+    public_group = create(:group, :public)
+
+    get :index
+
+    expect(assigns(:groups)).to contain_exactly(member_of_group, public_group)
+  end
+end
diff --git a/spec/controllers/groups/children_controller_spec.rb b/spec/controllers/groups/children_controller_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4262d474e59298926e11d527c0cd5338deec2685
--- /dev/null
+++ b/spec/controllers/groups/children_controller_spec.rb
@@ -0,0 +1,286 @@
+require 'spec_helper'
+
+describe Groups::ChildrenController do
+  let(:group) { create(:group, :public) }
+  let(:user) { create(:user) }
+  let!(:group_member) { create(:group_member, group: group, user: user) }
+
+  describe 'GET #index' do
+    context 'for projects' do
+      let!(:public_project) { create(:project, :public, namespace: group) }
+      let!(:private_project) { create(:project, :private, namespace: group) }
+
+      context 'as a user' do
+        before do
+          sign_in(user)
+        end
+
+        it 'shows all children' do
+          get :index, group_id: group.to_param, format: :json
+
+          expect(assigns(:children)).to contain_exactly(public_project, private_project)
+        end
+
+        context 'being member of private subgroup' do
+          it 'shows public and private children the user is member of' do
+            group_member.destroy!
+            private_project.add_guest(user)
+
+            get :index, group_id: group.to_param, format: :json
+
+            expect(assigns(:children)).to contain_exactly(public_project, private_project)
+          end
+        end
+      end
+
+      context 'as a guest' do
+        it 'shows the public children' do
+          get :index, group_id: group.to_param, format: :json
+
+          expect(assigns(:children)).to contain_exactly(public_project)
+        end
+      end
+    end
+
+    context 'for subgroups', :nested_groups do
+      let!(:public_subgroup) { create(:group, :public, parent: group) }
+      let!(:private_subgroup) { create(:group, :private, parent: group) }
+      let!(:public_project) { create(:project, :public, namespace: group) }
+      let!(:private_project) { create(:project, :private, namespace: group) }
+
+      context 'as a user' do
+        before do
+          sign_in(user)
+        end
+
+        it 'shows all children' do
+          get :index, group_id: group.to_param, format: :json
+
+          expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
+        end
+
+        context 'being member of private subgroup' do
+          it 'shows public and private children the user is member of' do
+            group_member.destroy!
+            private_subgroup.add_guest(user)
+            private_project.add_guest(user)
+
+            get :index, group_id: group.to_param, format: :json
+
+            expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
+          end
+        end
+      end
+
+      context 'as a guest' do
+        it 'shows the public children' do
+          get :index, group_id: group.to_param, format: :json
+
+          expect(assigns(:children)).to contain_exactly(public_subgroup, public_project)
+        end
+      end
+
+      context 'filtering children' do
+        it 'expands the tree for matching projects' do
+          project = create(:project, :public, namespace: public_subgroup, name: 'filterme')
+
+          get :index, group_id: group.to_param, filter: 'filter', format: :json
+
+          group_json = json_response.first
+          project_json = group_json['children'].first
+
+          expect(group_json['id']).to eq(public_subgroup.id)
+          expect(project_json['id']).to eq(project.id)
+        end
+
+        it 'expands the tree for matching subgroups' do
+          matched_group = create(:group, :public, parent: public_subgroup, name: 'filterme')
+
+          get :index, group_id: group.to_param, filter: 'filter', format: :json
+
+          group_json = json_response.first
+          matched_group_json = group_json['children'].first
+
+          expect(group_json['id']).to eq(public_subgroup.id)
+          expect(matched_group_json['id']).to eq(matched_group.id)
+        end
+
+        it 'merges the trees correctly' do
+          shared_subgroup = create(:group, :public, parent: group, path: 'hardware')
+          matched_project_1 = create(:project, :public, namespace: shared_subgroup, name: 'mobile-soc')
+
+          l2_subgroup = create(:group, :public, parent: shared_subgroup, path: 'broadcom')
+          l3_subgroup = create(:group, :public,  parent: l2_subgroup, path: 'wifi-group')
+          matched_project_2 = create(:project, :public, namespace: l3_subgroup, name: 'mobile')
+
+          get :index, group_id: group.to_param, filter: 'mobile', format: :json
+
+          shared_group_json = json_response.first
+          expect(shared_group_json['id']).to eq(shared_subgroup.id)
+
+          matched_project_1_json = shared_group_json['children'].detect { |child| child['type'] == 'project' }
+          expect(matched_project_1_json['id']).to eq(matched_project_1.id)
+
+          l2_subgroup_json = shared_group_json['children'].detect { |child| child['type'] == 'group' }
+          expect(l2_subgroup_json['id']).to eq(l2_subgroup.id)
+
+          l3_subgroup_json = l2_subgroup_json['children'].first
+          expect(l3_subgroup_json['id']).to eq(l3_subgroup.id)
+
+          matched_project_2_json = l3_subgroup_json['children'].first
+          expect(matched_project_2_json['id']).to eq(matched_project_2.id)
+        end
+
+        it 'expands the tree upto a specified parent' do
+          subgroup = create(:group, :public, parent: group)
+          l2_subgroup = create(:group, :public, parent: subgroup)
+          create(:project, :public, namespace: l2_subgroup, name: 'test')
+
+          get :index, group_id: subgroup.to_param, filter: 'test', format: :json
+
+          expect(response).to have_http_status(200)
+        end
+
+        it 'returns an array with one element when only one result is matched' do
+          create(:project, :public, namespace: group, name: 'match')
+
+          get :index, group_id: group.to_param, filter: 'match', format: :json
+
+          expect(json_response).to be_kind_of(Array)
+          expect(json_response.size).to eq(1)
+        end
+
+        it 'returns an empty array when there are no search results' do
+          subgroup = create(:group, :public, parent: group)
+          l2_subgroup = create(:group, :public, parent: subgroup)
+          create(:project, :public, namespace: l2_subgroup, name: 'no-match')
+
+          get :index, group_id: subgroup.to_param, filter: 'test', format: :json
+
+          expect(json_response).to eq([])
+        end
+
+        it 'includes pagination headers' do
+          2.times { |i| create(:group, :public, parent: public_subgroup, name: "filterme#{i}") }
+
+          get :index, group_id: group.to_param, filter: 'filter', per_page: 1, format: :json
+
+          expect(response).to include_pagination_headers
+        end
+      end
+
+      context 'queries per rendered element', :request_store do
+        # We need to make sure the following counts are preloaded
+        # otherwise they will cause an extra query
+        # 1. Count of visible projects in the element
+        # 2. Count of visible subgroups in the element
+        # 3. Count of members of a group
+        let(:expected_queries_per_group) { 0 }
+        let(:expected_queries_per_project) { 0 }
+
+        def get_list
+          get :index, group_id: group.to_param, format: :json
+        end
+
+        it 'queries the expected amount for a group row' do
+          control = ActiveRecord::QueryRecorder.new { get_list }
+
+          _new_group = create(:group, :public, parent: group)
+
+          expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_group)
+        end
+
+        it 'queries the expected amount for a project row' do
+          control = ActiveRecord::QueryRecorder.new { get_list }
+          _new_project = create(:project, :public, namespace: group)
+
+          expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_project)
+        end
+
+        context 'when rendering hierarchies' do
+          # When loading hierarchies we load the all the ancestors for matched projects
+          # in 1 separate query
+          let(:extra_queries_for_hierarchies) { 1 }
+
+          def get_filtered_list
+            get :index, group_id: group.to_param, filter: 'filter', format: :json
+          end
+
+          it 'queries the expected amount when nested rows are increased for a group' do
+            matched_group = create(:group, :public, parent: group, name: 'filterme')
+
+            control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+            matched_group.update!(parent: public_subgroup)
+
+            expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+          end
+
+          it 'queries the expected amount when a new group match is added' do
+            create(:group, :public, parent: public_subgroup, name: 'filterme')
+
+            control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+            create(:group, :public, parent: public_subgroup, name: 'filterme2')
+            create(:group, :public, parent: public_subgroup, name: 'filterme3')
+
+            expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+          end
+
+          it 'queries the expected amount when nested rows are increased for a project' do
+            matched_project = create(:project, :public, namespace: group, name: 'filterme')
+
+            control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+            matched_project.update!(namespace: public_subgroup)
+
+            expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+          end
+        end
+      end
+    end
+
+    context 'pagination' do
+      let(:per_page) { 3 }
+
+      before do
+        allow(Kaminari.config).to receive(:default_per_page).and_return(per_page)
+      end
+
+      context 'with only projects' do
+        let!(:other_project) { create(:project, :public, namespace: group) }
+        let!(:first_page_projects) { create_list(:project, per_page, :public, namespace: group ) }
+
+        it 'has projects on the first page' do
+          get :index, group_id: group.to_param, sort: 'id_desc', format: :json
+
+          expect(assigns(:children)).to contain_exactly(*first_page_projects)
+        end
+
+        it 'has projects on the second page' do
+          get :index, group_id: group.to_param, sort: 'id_desc', page: 2, format: :json
+
+          expect(assigns(:children)).to contain_exactly(other_project)
+        end
+      end
+
+      context 'with subgroups and projects', :nested_groups do
+        let!(:first_page_subgroups) { create_list(:group,  per_page, :public,  parent: group) }
+        let!(:other_subgroup) { create(:group, :public, parent: group) }
+        let!(:next_page_projects) { create_list(:project, per_page, :public, namespace: group) }
+
+        it 'contains all subgroups' do
+          get :index, group_id: group.to_param, sort: 'id_asc', format: :json
+
+          expect(assigns(:children)).to contain_exactly(*first_page_subgroups)
+        end
+
+        it 'contains the project and group on the second page' do
+          get :index, group_id: group.to_param, sort: 'id_asc', page: 2, format: :json
+
+          expect(assigns(:children)).to contain_exactly(other_subgroup, *next_page_projects.take(per_page - 1))
+        end
+      end
+    end
+  end
+end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index b0564e27a68e36660309944926dc81d0dd503a7b..e7631d4d709de24e07de17eaaaa97e69c669541a 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -1,4 +1,4 @@
-require 'rails_helper'
+require 'spec_helper'
 
 describe GroupsController do
   let(:user) { create(:user) }
@@ -150,42 +150,6 @@ describe GroupsController do
     end
   end
 
-  describe 'GET #subgroups', :nested_groups do
-    let!(:public_subgroup) { create(:group, :public, parent: group) }
-    let!(:private_subgroup) { create(:group, :private, parent: group) }
-
-    context 'as a user' do
-      before do
-        sign_in(user)
-      end
-
-      it 'shows all subgroups' do
-        get :subgroups, id: group.to_param
-
-        expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
-      end
-
-      context 'being member of private subgroup' do
-        it 'shows public and private subgroups the user is member of' do
-          group_member.destroy!
-          private_subgroup.add_guest(user)
-
-          get :subgroups, id: group.to_param
-
-          expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
-        end
-      end
-    end
-
-    context 'as a guest' do
-      it 'shows the public subgroups' do
-        get :subgroups, id: group.to_param
-
-        expect(assigns(:nested_groups)).to contain_exactly(public_subgroup)
-      end
-    end
-  end
-
   describe 'GET #issues' do
     let(:issue_1) { create(:issue, project: project) }
     let(:issue_2) { create(:issue, project: project) }
@@ -425,62 +389,62 @@ describe GroupsController do
           end
         end
       end
-    end
 
-    context 'for a POST request' do
-      context 'when requesting the canonical path with different casing' do
-        it 'does not 404' do
-          post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+      context 'for a POST request' do
+        context 'when requesting the canonical path with different casing' do
+          it 'does not 404' do
+            post :update, id: group.to_param.upcase, group: { path: 'new_path' }
 
-          expect(response).not_to have_http_status(404)
-        end
+            expect(response).not_to have_http_status(404)
+          end
 
-        it 'does not redirect to the correct casing' do
-          post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+          it 'does not redirect to the correct casing' do
+            post :update, id: group.to_param.upcase, group: { path: 'new_path' }
 
-          expect(response).not_to have_http_status(301)
+            expect(response).not_to have_http_status(301)
+          end
         end
-      end
 
-      context 'when requesting a redirected path' do
-        let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+        context 'when requesting a redirected path' do
+          let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
 
-        it 'returns not found' do
-          post :update, id: redirect_route.path, group: { path: 'new_path' }
+          it 'returns not found' do
+            post :update, id: redirect_route.path, group: { path: 'new_path' }
 
-          expect(response).to have_http_status(404)
+            expect(response).to have_http_status(404)
+          end
         end
       end
-    end
 
-    context 'for a DELETE request' do
-      context 'when requesting the canonical path with different casing' do
-        it 'does not 404' do
-          delete :destroy, id: group.to_param.upcase
+      context 'for a DELETE request' do
+        context 'when requesting the canonical path with different casing' do
+          it 'does not 404' do
+            delete :destroy, id: group.to_param.upcase
 
-          expect(response).not_to have_http_status(404)
-        end
+            expect(response).not_to have_http_status(404)
+          end
 
-        it 'does not redirect to the correct casing' do
-          delete :destroy, id: group.to_param.upcase
+          it 'does not redirect to the correct casing' do
+            delete :destroy, id: group.to_param.upcase
 
-          expect(response).not_to have_http_status(301)
+            expect(response).not_to have_http_status(301)
+          end
         end
-      end
 
-      context 'when requesting a redirected path' do
-        let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+        context 'when requesting a redirected path' do
+          let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
 
-        it 'returns not found' do
-          delete :destroy, id: redirect_route.path
+          it 'returns not found' do
+            delete :destroy, id: redirect_route.path
 
-          expect(response).to have_http_status(404)
+            expect(response).to have_http_status(404)
+          end
         end
       end
     end
-  end
 
-  def group_moved_message(redirect_route, group)
-    "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
+    def group_moved_message(redirect_route, group)
+      "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
+    end
   end
 end
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 167e80ed9cdedb21112533e534b13d89825a49a2..67b53d2acce051a72991b16fc5311b787b71a059 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -3,32 +3,36 @@ require 'spec_helper'
 describe Projects::PipelinesController do
   include ApiHelpers
 
-  let(:user) { create(:user) }
-  let(:project) { create(:project, :public) }
+  set(:user) { create(:user) }
+  set(:project) { create(:project, :public, :repository) }
   let(:feature) { ProjectFeature::DISABLED }
 
   before do
     stub_not_protect_default_branch
     project.add_developer(user)
-    project.project_feature.update(
-      builds_access_level: feature)
+    project.project_feature.update(builds_access_level: feature)
 
     sign_in(user)
   end
 
   describe 'GET index.json' do
     before do
-      create(:ci_empty_pipeline, status: 'pending', project: project)
-      create(:ci_empty_pipeline, status: 'running', project: project)
-      create(:ci_empty_pipeline, status: 'created', project: project)
-      create(:ci_empty_pipeline, status: 'success', project: project)
+      branch_head = project.commit
+      parent = branch_head.parent
 
-      get :index, namespace_id: project.namespace,
-                  project_id: project,
-                  format: :json
+      create(:ci_empty_pipeline, status: 'pending', project: project, sha: branch_head.id)
+      create(:ci_empty_pipeline, status: 'running', project: project, sha: branch_head.id)
+      create(:ci_empty_pipeline, status: 'created', project: project, sha: parent.id)
+      create(:ci_empty_pipeline, status: 'success', project: project, sha: parent.id)
+    end
+
+    subject do
+      get :index, namespace_id: project.namespace, project_id: project, format: :json
     end
 
     it 'returns JSON with serialized pipelines' do
+      subject
+
       expect(response).to have_http_status(:ok)
       expect(response).to match_response_schema('pipeline')
 
@@ -39,6 +43,12 @@ describe Projects::PipelinesController do
       expect(json_response['count']['pending']).to eq 1
       expect(json_response['count']['finished']).to eq 1
     end
+
+    context 'when performing gitaly calls', :request_store do
+      it 'limits the Gitaly requests' do
+        expect { subject }.to change { Gitlab::GitalyClient.get_request_count }.by(10)
+      end
+    end
   end
 
   describe 'GET show JSON' do
diff --git a/spec/features/admin/admin_health_check_spec.rb b/spec/features/admin/admin_health_check_spec.rb
index 09e6965849ac5cd3f6d1ac28255ff2ed82ebdadf..4430fc155018fa0a99e1b59acb328b442e8ab031 100644
--- a/spec/features/admin/admin_health_check_spec.rb
+++ b/spec/features/admin/admin_health_check_spec.rb
@@ -65,9 +65,11 @@ feature "Admin Health Check", :feature, :broken_storage do
 
     it 'shows storage failure information' do
       hostname = Gitlab::Environment.hostname
+      maximum_failures = Gitlab::CurrentSettings.current_application_settings
+                           .circuitbreaker_failure_count_threshold
 
       expect(page).to have_content('broken: failed storage access attempt on host:')
-      expect(page).to have_content("#{hostname}: 1 of 10 failures.")
+      expect(page).to have_content("#{hostname}: 1 of #{maximum_failures} failures.")
     end
 
     it 'allows resetting storage failures' do
diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb
index a6329b5c78d309c1a9a2535fd7fad85d28a7705f..c6873d1923cc2b632745655fdc55c7a225579988 100644
--- a/spec/features/dashboard/groups_list_spec.rb
+++ b/spec/features/dashboard/groups_list_spec.rb
@@ -6,6 +6,13 @@ feature 'Dashboard Groups page', :js do
   let(:nested_group) { create(:group, :nested) }
   let(:another_group) { create(:group) }
 
+  def click_group_caret(group)
+    within("#group-#{group.id}") do
+      first('.folder-caret').click
+    end
+    wait_for_requests
+  end
+
   it 'shows groups user is member of' do
     group.add_owner(user)
     nested_group.add_owner(user)
@@ -13,13 +20,27 @@ feature 'Dashboard Groups page', :js do
 
     sign_in(user)
     visit dashboard_groups_path
+    wait_for_requests
+
+    expect(page).to have_content(group.name)
+
+    expect(page).not_to have_content(another_group.name)
+  end
+
+  it 'shows subgroups the user is member of', :nested_groups do
+    group.add_owner(user)
+    nested_group.add_owner(user)
+
+    sign_in(user)
+    visit dashboard_groups_path
+    wait_for_requests
 
-    expect(page).to have_content(group.full_name)
-    expect(page).to have_content(nested_group.full_name)
-    expect(page).not_to have_content(another_group.full_name)
+    expect(page).to have_content(nested_group.parent.name)
+    click_group_caret(nested_group.parent)
+    expect(page).to have_content(nested_group.name)
   end
 
-  describe 'when filtering groups' do
+  describe 'when filtering groups', :nested_groups do
     before do
       group.add_owner(user)
       nested_group.add_owner(user)
@@ -30,25 +51,26 @@ feature 'Dashboard Groups page', :js do
       visit dashboard_groups_path
     end
 
-    it 'filters groups' do
-      fill_in 'filter_groups', with: group.name
+    it 'expands when filtering groups' do
+      fill_in 'filter', with: nested_group.name
       wait_for_requests
 
-      expect(page).to have_content(group.full_name)
-      expect(page).not_to have_content(nested_group.full_name)
-      expect(page).not_to have_content(another_group.full_name)
+      expect(page).not_to have_content(group.name)
+      expect(page).to have_content(nested_group.parent.name)
+      expect(page).to have_content(nested_group.name)
+      expect(page).not_to have_content(another_group.name)
     end
 
     it 'resets search when user cleans the input' do
-      fill_in 'filter_groups', with: group.name
+      fill_in 'filter', with: group.name
       wait_for_requests
 
-      fill_in 'filter_groups', with: ''
+      fill_in 'filter', with: ''
       wait_for_requests
 
-      expect(page).to have_content(group.full_name)
-      expect(page).to have_content(nested_group.full_name)
-      expect(page).not_to have_content(another_group.full_name)
+      expect(page).to have_content(group.name)
+      expect(page).to have_content(nested_group.parent.name)
+      expect(page).not_to have_content(another_group.name)
       expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
     end
   end
@@ -66,28 +88,29 @@ feature 'Dashboard Groups page', :js do
     end
 
     it 'shows subgroups inside of its parent group' do
-      expect(page).to have_selector('.groups-list-tree-container .group-list-tree', count: 2)
-      expect(page).to have_selector(".groups-list-tree-container #group-#{group.id} #group-#{subgroup.id}", count: 1)
+      expect(page).to have_selector("#group-#{group.id}")
+      click_group_caret(group)
+      expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
     end
 
     it 'can toggle parent group' do
-      # Expanded by default
-      expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
-      expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
+      # Collapsed by default
+      expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+      expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
 
-      # Collapse
-      find("#group-#{group.id}").trigger('click')
+      # expand
+      click_group_caret(group)
 
-      expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down")
-      expect(page).to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
-      expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
+      expect(page).to have_selector("#group-#{group.id} .fa-caret-down")
+      expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
+      expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
 
-      # Expand
-      find("#group-#{group.id}").trigger('click')
+      # collapse
+      click_group_caret(group)
 
-      expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
-      expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
-      expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
+      expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+      expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
+      expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
     end
   end
 
diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb
index b53253019689664869e8b911766b293e2720f8ce..801a33979ff6e3bdd90e7561c54ce0d9842c9378 100644
--- a/spec/features/explore/groups_list_spec.rb
+++ b/spec/features/explore/groups_list_spec.rb
@@ -13,6 +13,7 @@ describe 'Explore Groups page', :js do
     sign_in(user)
 
     visit explore_groups_path
+    wait_for_requests
   end
 
   it 'shows groups user is member of' do
@@ -22,7 +23,7 @@ describe 'Explore Groups page', :js do
   end
 
   it 'filters groups' do
-    fill_in 'filter_groups', with: group.name
+    fill_in 'filter', with: group.name
     wait_for_requests
 
     expect(page).to have_content(group.full_name)
@@ -31,10 +32,10 @@ describe 'Explore Groups page', :js do
   end
 
   it 'resets search when user cleans the input' do
-    fill_in 'filter_groups', with: group.name
+    fill_in 'filter', with: group.name
     wait_for_requests
 
-    fill_in 'filter_groups', with: ""
+    fill_in 'filter', with: ""
     wait_for_requests
 
     expect(page).to have_content(group.full_name)
@@ -45,21 +46,21 @@ describe 'Explore Groups page', :js do
 
   it 'shows non-archived projects count' do
     # Initially project is not archived
-    expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+    expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
 
     # Archive project
     empty_project.archive!
     visit explore_groups_path
 
     # Check project count
-    expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("0")
+    expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("0")
 
     # Unarchive project
     empty_project.unarchive!
     visit explore_groups_path
 
     # Check project count
-    expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+    expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
   end
 
   describe 'landing component' do
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index 303013e59d541c43d84f3e25194c5da652dc6ff4..7fc2b383749d479098fe43fef9ca278ca2a31eb8 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -24,4 +24,35 @@ feature 'Group show page' do
 
     it_behaves_like "an autodiscoverable RSS feed without an RSS token"
   end
+
+  context 'subgroup support' do
+    let(:user) { create(:user) }
+
+    before do
+      group.add_owner(user)
+      sign_in(user)
+    end
+
+    context 'when subgroups are supported', :js, :nested_groups do
+      before do
+        allow(Group).to receive(:supports_nested_groups?) { true }
+        visit path
+      end
+
+      it 'allows creating subgroups' do
+        expect(page).to have_css("li[data-text='New subgroup']", visible: false)
+      end
+    end
+
+    context 'when subgroups are not supported' do
+      before do
+        allow(Group).to receive(:supports_nested_groups?) { false }
+        visit path
+      end
+
+      it 'allows creating subgroups' do
+        expect(page).not_to have_selector("li[data-text='New subgroup']", visible: false)
+      end
+    end
+  end
 end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 862823d862e6ebc6e781bfa0d9408b33dbc99599..cc8906fa969503471046797e75adf4e7de63526b 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -90,8 +90,7 @@ feature 'Group' do
 
     context 'as admin' do
       before do
-        visit subgroups_group_path(group)
-        click_link 'New Subgroup'
+        visit new_group_path(group, parent_id: group.id)
       end
 
       it 'creates a nested group' do
@@ -111,8 +110,8 @@ feature 'Group' do
         sign_out(:user)
         sign_in(user)
 
-        visit subgroups_group_path(group)
-        click_link 'New Subgroup'
+        visit new_group_path(group, parent_id: group.id)
+
         fill_in 'Group path', with: 'bar'
         click_button 'Create group'
 
@@ -120,16 +119,6 @@ feature 'Group' do
         expect(page).to have_content("Group 'bar' was successfully created.")
       end
     end
-
-    context 'when nested group feature is disabled' do
-      it 'renders 404' do
-        allow(Group).to receive(:supports_nested_groups?).and_return(false)
-
-        visit subgroups_group_path(group)
-
-        expect(page.status_code).to eq(404)
-      end
-    end
   end
 
   it 'checks permissions to avoid exposing groups by parent_id' do
@@ -210,13 +199,15 @@ feature 'Group' do
   describe 'group page with nested groups', :nested_groups, :js do
     let!(:group) { create(:group) }
     let!(:nested_group) { create(:group, parent: group) }
+    let!(:project) { create(:project, namespace: group) }
     let!(:path)  { group_path(group) }
 
-    it 'has nested groups tab with nested groups inside' do
+    it 'it renders projects and groups on the page' do
       visit path
-      click_link 'Subgroups'
+      wait_for_requests
 
       expect(page).to have_content(nested_group.name)
+      expect(page).to have_content(project.name)
     end
   end
 
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 3bc7ec3123fad9db172a45a5d861d71da2de9c06..3b01ed442bfa8671143a39950707295181fc01ff 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -139,7 +139,7 @@ feature 'Project' do
 
     it 'removes a project' do
       expect { remove_with_confirm('Remove project', project.path) }.to change {Project.count}.by(-1)
-      expect(page).to have_content "Project 'test / project1' will be deleted."
+      expect(page).to have_content "Project 'test / project1' is in the process of being deleted."
       expect(Project.all.count).to be_zero
       expect(project.issues).to be_empty
       expect(project.merge_requests).to be_empty
diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..074914420a144ce71503c2dbf527f54955c9056e
--- /dev/null
+++ b/spec/finders/group_descendants_finder_spec.rb
@@ -0,0 +1,166 @@
+require 'spec_helper'
+
+describe GroupDescendantsFinder do
+  let(:user) { create(:user) }
+  let(:group) { create(:group) }
+  let(:params) { {} }
+  subject(:finder) do
+    described_class.new(current_user: user, parent_group: group, params: params)
+  end
+
+  before do
+    group.add_owner(user)
+  end
+
+  describe '#has_children?' do
+    it 'is true when there are projects' do
+      create(:project, namespace: group)
+
+      expect(finder.has_children?).to be_truthy
+    end
+
+    context 'when there are subgroups', :nested_groups do
+      it 'is true when there are projects' do
+        create(:group, parent: group)
+
+        expect(finder.has_children?).to be_truthy
+      end
+    end
+  end
+
+  describe '#execute' do
+    it 'includes projects' do
+      project = create(:project, namespace: group)
+
+      expect(finder.execute).to contain_exactly(project)
+    end
+
+    context 'when archived is `true`' do
+      let(:params) { { archived: 'true' } }
+
+      it 'includes archived projects' do
+        archived_project = create(:project, namespace: group, archived: true)
+        project = create(:project, namespace: group)
+
+        expect(finder.execute).to contain_exactly(archived_project, project)
+      end
+    end
+
+    context 'when archived is `only`' do
+      let(:params) { { archived: 'only' } }
+
+      it 'includes only archived projects' do
+        archived_project = create(:project, namespace: group, archived: true)
+        _project = create(:project, namespace: group)
+
+        expect(finder.execute).to contain_exactly(archived_project)
+      end
+    end
+
+    it 'does not include archived projects' do
+      _archived_project = create(:project, :archived, namespace: group)
+
+      expect(finder.execute).to be_empty
+    end
+
+    context 'with a filter' do
+      let(:params) { { filter: 'test' } }
+
+      it 'includes only projects matching the filter' do
+        _other_project = create(:project, namespace: group)
+        matching_project = create(:project, namespace: group, name: 'testproject')
+
+        expect(finder.execute).to contain_exactly(matching_project)
+      end
+    end
+  end
+
+  context 'with nested groups', :nested_groups do
+    let!(:project) { create(:project, namespace: group) }
+    let!(:subgroup) { create(:group, :private, parent: group) }
+
+    describe '#execute' do
+      it 'contains projects and subgroups' do
+        expect(finder.execute).to contain_exactly(subgroup, project)
+      end
+
+      it 'does not include subgroups the user does not have access to' do
+        subgroup.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+
+        public_subgroup = create(:group, :public, parent: group, path: 'public-group')
+        other_subgroup = create(:group, :private, parent: group, path: 'visible-private-group')
+        other_user = create(:user)
+        other_subgroup.add_developer(other_user)
+
+        finder = described_class.new(current_user: other_user, parent_group: group)
+
+        expect(finder.execute).to contain_exactly(public_subgroup, other_subgroup)
+      end
+
+      it 'only includes public groups when no user is given' do
+        public_subgroup = create(:group, :public, parent: group)
+        _private_subgroup = create(:group, :private, parent: group)
+
+        finder = described_class.new(current_user: nil, parent_group: group)
+
+        expect(finder.execute).to contain_exactly(public_subgroup)
+      end
+
+      context 'when archived is `true`' do
+        let(:params) { { archived: 'true' } }
+
+        it 'includes archived projects in the count of subgroups' do
+          create(:project, namespace: subgroup, archived: true)
+
+          expect(finder.execute.first.preloaded_project_count).to eq(1)
+        end
+      end
+
+      context 'with a filter' do
+        let(:params) { { filter: 'test' } }
+
+        it 'contains only matching projects and subgroups' do
+          matching_project = create(:project, namespace: group, name: 'Testproject')
+          matching_subgroup = create(:group, name: 'testgroup', parent: group)
+
+          expect(finder.execute).to contain_exactly(matching_subgroup, matching_project)
+        end
+
+        it 'does not include subgroups the user does not have access to' do
+          _invisible_subgroup = create(:group, :private, parent: group, name: 'test1')
+          other_subgroup = create(:group, :private, parent: group, name: 'test2')
+          public_subgroup = create(:group, :public, parent: group, name: 'test3')
+          other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4')
+          other_user = create(:user)
+          other_subgroup.add_developer(other_user)
+
+          finder = described_class.new(current_user: other_user,
+                                       parent_group: group,
+                                       params: params)
+
+          expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup)
+        end
+
+        context 'with matching children' do
+          it 'includes a group that has a subgroup matching the query and its parent' do
+            matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup)
+
+            expect(finder.execute).to contain_exactly(subgroup, matching_subgroup)
+          end
+
+          it 'includes the parent of a matching project' do
+            matching_project = create(:project, namespace: subgroup, name: 'Testproject')
+
+            expect(finder.execute).to contain_exactly(subgroup, matching_project)
+          end
+
+          it 'does not include the parent itself' do
+            group.update!(name: 'test')
+
+            expect(finder.execute).not_to include(group)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/initializers/settings_spec.rb b/spec/initializers/settings_spec.rb
index 9a974e70e8c79fe54e45f54a26b2bf45b4aa5174..a11824d0ac5741ec1c08f6255e0524d21abd0142 100644
--- a/spec/initializers/settings_spec.rb
+++ b/spec/initializers/settings_spec.rb
@@ -18,26 +18,6 @@ describe Settings do
     end
   end
 
-  describe '#repositories' do
-    it 'assigns the default failure attributes' do
-      repository_settings = Gitlab.config.repositories.storages['broken']
-
-      expect(repository_settings['failure_count_threshold']).to eq(10)
-      expect(repository_settings['failure_wait_time']).to eq(30)
-      expect(repository_settings['failure_reset_time']).to eq(1800)
-      expect(repository_settings['storage_timeout']).to eq(5)
-    end
-
-    it 'can be accessed with dot syntax all the way down' do
-      expect(Gitlab.config.repositories.storages.broken.failure_count_threshold).to eq(10)
-    end
-
-    it 'can be accessed in a very specific way that breaks without reassigning each element with Settingslogic' do
-      storage_settings = Gitlab.config.repositories.storages['broken']
-      expect(storage_settings.failure_count_threshold).to eq(10)
-    end
-  end
-
   describe '#host_without_www' do
     context 'URL with protocol' do
       it 'returns the host' do
diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..cd19a0fae1e3c374d3f3d3dc1c05bfc8a3d89454
--- /dev/null
+++ b/spec/javascripts/groups/components/app_spec.js
@@ -0,0 +1,443 @@
+import Vue from 'vue';
+
+import appComponent from '~/groups/components/app.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+
+import eventHub from '~/groups/event_hub';
+import GroupsStore from '~/groups/store/groups_store';
+import GroupsService from '~/groups/service/groups_service';
+
+import {
+  mockEndpoint, mockGroups, mockSearchedGroups,
+  mockRawPageInfo, mockParentGroupItem, mockRawChildren,
+  mockChildren, mockPageInfo,
+} from '../mock_data';
+
+const createComponent = (hideProjects = false) => {
+  const Component = Vue.extend(appComponent);
+  const store = new GroupsStore(false);
+  const service = new GroupsService(mockEndpoint);
+
+  return new Component({
+    propsData: {
+      store,
+      service,
+      hideProjects,
+    },
+  });
+};
+
+const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
+  if (failed) {
+    reject(data);
+  } else {
+    resolve({
+      json() {
+        return data;
+      },
+    });
+  }
+});
+
+describe('AppComponent', () => {
+  let vm;
+
+  beforeEach((done) => {
+    Vue.component('group-folder', groupFolderComponent);
+    Vue.component('group-item', groupItemComponent);
+
+    vm = createComponent();
+
+    Vue.nextTick(() => {
+      done();
+    });
+  });
+
+  describe('computed', () => {
+    beforeEach(() => {
+      vm.$mount();
+    });
+
+    afterEach(() => {
+      vm.$destroy();
+    });
+
+    describe('groups', () => {
+      it('should return list of groups from store', () => {
+        spyOn(vm.store, 'getGroups');
+
+        const groups = vm.groups;
+        expect(vm.store.getGroups).toHaveBeenCalled();
+        expect(groups).not.toBeDefined();
+      });
+    });
+
+    describe('pageInfo', () => {
+      it('should return pagination info from store', () => {
+        spyOn(vm.store, 'getPaginationInfo');
+
+        const pageInfo = vm.pageInfo;
+        expect(vm.store.getPaginationInfo).toHaveBeenCalled();
+        expect(pageInfo).not.toBeDefined();
+      });
+    });
+  });
+
+  describe('methods', () => {
+    beforeEach(() => {
+      vm.$mount();
+    });
+
+    afterEach(() => {
+      vm.$destroy();
+    });
+
+    describe('fetchGroups', () => {
+      it('should call `getGroups` with all the params provided', (done) => {
+        spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups));
+
+        vm.fetchGroups({
+          parentId: 1,
+          page: 2,
+          filterGroupsBy: 'git',
+          sortBy: 'created_desc',
+          archived: true,
+        });
+        setTimeout(() => {
+          expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true);
+          done();
+        }, 0);
+      });
+
+      it('should set headers to store for building pagination info when called with `updatePagination`', (done) => {
+        spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise({ headers: mockRawPageInfo }));
+        spyOn(vm, 'updatePagination');
+
+        vm.fetchGroups({ updatePagination: true });
+        setTimeout(() => {
+          expect(vm.service.getGroups).toHaveBeenCalled();
+          expect(vm.updatePagination).toHaveBeenCalled();
+          done();
+        }, 0);
+      });
+
+      it('should show flash error when request fails', (done) => {
+        spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true));
+        spyOn($, 'scrollTo');
+        spyOn(window, 'Flash');
+
+        vm.fetchGroups({});
+        setTimeout(() => {
+          expect(vm.isLoading).toBeFalsy();
+          expect($.scrollTo).toHaveBeenCalledWith(0);
+          expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
+          done();
+        }, 0);
+      });
+    });
+
+    describe('fetchAllGroups', () => {
+      it('should fetch default set of groups', (done) => {
+        spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
+        spyOn(vm, 'updatePagination').and.callThrough();
+        spyOn(vm, 'updateGroups').and.callThrough();
+
+        vm.fetchAllGroups();
+        expect(vm.isLoading).toBeTruthy();
+        expect(vm.fetchGroups).toHaveBeenCalled();
+        setTimeout(() => {
+          expect(vm.isLoading).toBeFalsy();
+          expect(vm.updateGroups).toHaveBeenCalled();
+          done();
+        }, 0);
+      });
+
+      it('should fetch matching set of groups when app is loaded with search query', (done) => {
+        spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups));
+        spyOn(vm, 'updateGroups').and.callThrough();
+
+        vm.fetchAllGroups();
+        expect(vm.fetchGroups).toHaveBeenCalledWith({
+          page: null,
+          filterGroupsBy: null,
+          sortBy: null,
+          updatePagination: true,
+          archived: null,
+        });
+        setTimeout(() => {
+          expect(vm.updateGroups).toHaveBeenCalled();
+          done();
+        }, 0);
+      });
+    });
+
+    describe('fetchPage', () => {
+      it('should fetch groups for provided page details and update window state', (done) => {
+        spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
+        spyOn(vm, 'updateGroups').and.callThrough();
+        spyOn(gl.utils, 'mergeUrlParams').and.callThrough();
+        spyOn(window.history, 'replaceState');
+        spyOn($, 'scrollTo');
+
+        vm.fetchPage(2, null, null, true);
+        expect(vm.isLoading).toBeTruthy();
+        expect(vm.fetchGroups).toHaveBeenCalledWith({
+          page: 2,
+          filterGroupsBy: null,
+          sortBy: null,
+          updatePagination: true,
+          archived: true,
+        });
+        setTimeout(() => {
+          expect(vm.isLoading).toBeFalsy();
+          expect($.scrollTo).toHaveBeenCalledWith(0);
+          expect(gl.utils.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
+          expect(window.history.replaceState).toHaveBeenCalledWith({
+            page: jasmine.any(String),
+          }, jasmine.any(String), jasmine.any(String));
+          expect(vm.updateGroups).toHaveBeenCalled();
+          done();
+        }, 0);
+      });
+    });
+
+    describe('toggleChildren', () => {
+      let groupItem;
+
+      beforeEach(() => {
+        groupItem = Object.assign({}, mockParentGroupItem);
+        groupItem.isOpen = false;
+        groupItem.isChildrenLoading = false;
+      });
+
+      it('should fetch children of given group and expand it if group is collapsed and children are not loaded', (done) => {
+        spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren));
+        spyOn(vm.store, 'setGroupChildren');
+
+        vm.toggleChildren(groupItem);
+        expect(groupItem.isChildrenLoading).toBeTruthy();
+        expect(vm.fetchGroups).toHaveBeenCalledWith({
+          parentId: groupItem.id,
+        });
+        setTimeout(() => {
+          expect(vm.store.setGroupChildren).toHaveBeenCalled();
+          done();
+        }, 0);
+      });
+
+      it('should skip network request while expanding group if children are already loaded', () => {
+        spyOn(vm, 'fetchGroups');
+        groupItem.children = mockRawChildren;
+
+        vm.toggleChildren(groupItem);
+        expect(vm.fetchGroups).not.toHaveBeenCalled();
+        expect(groupItem.isOpen).toBeTruthy();
+      });
+
+      it('should collapse group if it is already expanded', () => {
+        spyOn(vm, 'fetchGroups');
+        groupItem.isOpen = true;
+
+        vm.toggleChildren(groupItem);
+        expect(vm.fetchGroups).not.toHaveBeenCalled();
+        expect(groupItem.isOpen).toBeFalsy();
+      });
+
+      it('should set `isChildrenLoading` back to `false` if load request fails', (done) => {
+        spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
+
+        vm.toggleChildren(groupItem);
+        expect(groupItem.isChildrenLoading).toBeTruthy();
+        setTimeout(() => {
+          expect(groupItem.isChildrenLoading).toBeFalsy();
+          done();
+        }, 0);
+      });
+    });
+
+    describe('leaveGroup', () => {
+      let groupItem;
+      let childGroupItem;
+
+      beforeEach(() => {
+        groupItem = Object.assign({}, mockParentGroupItem);
+        groupItem.children = mockChildren;
+        childGroupItem = groupItem.children[0];
+        groupItem.isChildrenLoading = false;
+      });
+
+      it('should leave group and remove group item from tree', (done) => {
+        const notice = `You left the "${childGroupItem.fullName}" group.`;
+        spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice }));
+        spyOn(vm.store, 'removeGroup').and.callThrough();
+        spyOn(window, 'Flash');
+        spyOn($, 'scrollTo');
+
+        vm.leaveGroup(childGroupItem, groupItem);
+        expect(childGroupItem.isBeingRemoved).toBeTruthy();
+        expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+        setTimeout(() => {
+          expect($.scrollTo).toHaveBeenCalledWith(0);
+          expect(vm.store.removeGroup).toHaveBeenCalledWith(childGroupItem, groupItem);
+          expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
+          done();
+        }, 0);
+      });
+
+      it('should show error flash message if request failed to leave group', (done) => {
+        const message = 'An error occurred. Please try again.';
+        spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 500 }, true));
+        spyOn(vm.store, 'removeGroup').and.callThrough();
+        spyOn(window, 'Flash');
+
+        vm.leaveGroup(childGroupItem, groupItem);
+        expect(childGroupItem.isBeingRemoved).toBeTruthy();
+        expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+        setTimeout(() => {
+          expect(vm.store.removeGroup).not.toHaveBeenCalled();
+          expect(window.Flash).toHaveBeenCalledWith(message);
+          expect(childGroupItem.isBeingRemoved).toBeFalsy();
+          done();
+        }, 0);
+      });
+
+      it('should show appropriate error flash message if request forbids to leave group', (done) => {
+        const message = 'Failed to leave the group. Please make sure you are not the only owner.';
+        spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 403 }, true));
+        spyOn(vm.store, 'removeGroup').and.callThrough();
+        spyOn(window, 'Flash');
+
+        vm.leaveGroup(childGroupItem, groupItem);
+        expect(childGroupItem.isBeingRemoved).toBeTruthy();
+        expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+        setTimeout(() => {
+          expect(vm.store.removeGroup).not.toHaveBeenCalled();
+          expect(window.Flash).toHaveBeenCalledWith(message);
+          expect(childGroupItem.isBeingRemoved).toBeFalsy();
+          done();
+        }, 0);
+      });
+    });
+
+    describe('updatePagination', () => {
+      it('should set pagination info to store from provided headers', () => {
+        spyOn(vm.store, 'setPaginationInfo');
+
+        vm.updatePagination(mockRawPageInfo);
+        expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo);
+      });
+    });
+
+    describe('updateGroups', () => {
+      it('should call setGroups on store if method was called directly', () => {
+        spyOn(vm.store, 'setGroups');
+
+        vm.updateGroups(mockGroups);
+        expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups);
+      });
+
+      it('should call setSearchedGroups on store if method was called with fromSearch param', () => {
+        spyOn(vm.store, 'setSearchedGroups');
+
+        vm.updateGroups(mockGroups, true);
+        expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups);
+      });
+
+      it('should set `isSearchEmpty` prop based on groups count', () => {
+        vm.updateGroups(mockGroups);
+        expect(vm.isSearchEmpty).toBeFalsy();
+
+        vm.updateGroups([]);
+        expect(vm.isSearchEmpty).toBeTruthy();
+      });
+    });
+  });
+
+  describe('created', () => {
+    it('should bind event listeners on eventHub', (done) => {
+      spyOn(eventHub, '$on');
+
+      const newVm = createComponent();
+      newVm.$mount();
+
+      Vue.nextTick(() => {
+        expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
+        expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
+        expect(eventHub.$on).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+        expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
+        expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
+        newVm.$destroy();
+        done();
+      });
+    });
+
+    it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (done) => {
+      const newVm = createComponent();
+      newVm.$mount();
+      Vue.nextTick(() => {
+        expect(newVm.searchEmptyMessage).toBe('Sorry, no groups or projects matched your search');
+        newVm.$destroy();
+        done();
+      });
+    });
+
+    it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (done) => {
+      const newVm = createComponent(true);
+      newVm.$mount();
+      Vue.nextTick(() => {
+        expect(newVm.searchEmptyMessage).toBe('Sorry, no groups matched your search');
+        newVm.$destroy();
+        done();
+      });
+    });
+  });
+
+  describe('beforeDestroy', () => {
+    it('should unbind event listeners on eventHub', (done) => {
+      spyOn(eventHub, '$off');
+
+      const newVm = createComponent();
+      newVm.$mount();
+      newVm.$destroy();
+
+      Vue.nextTick(() => {
+        expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
+        expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
+        expect(eventHub.$off).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+        expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
+        expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
+        done();
+      });
+    });
+  });
+
+  describe('template', () => {
+    beforeEach(() => {
+      vm.$mount();
+    });
+
+    afterEach(() => {
+      vm.$destroy();
+    });
+
+    it('should render loading icon', (done) => {
+      vm.isLoading = true;
+      Vue.nextTick(() => {
+        expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
+        expect(vm.$el.querySelector('i.fa').getAttribute('aria-label')).toBe('Loading groups');
+        done();
+      });
+    });
+
+    it('should render groups tree', (done) => {
+      vm.groups = [mockParentGroupItem];
+      vm.isLoading = false;
+      vm.pageInfo = mockPageInfo;
+      Vue.nextTick(() => {
+        expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+        done();
+      });
+    });
+  });
+});
diff --git a/spec/javascripts/groups/components/group_folder_spec.js b/spec/javascripts/groups/components/group_folder_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4eb198595fb96a8ac0e378cbaf879b5adde5ddf1
--- /dev/null
+++ b/spec/javascripts/groups/components/group_folder_spec.js
@@ -0,0 +1,66 @@
+import Vue from 'vue';
+
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import { mockGroups, mockParentGroupItem } from '../mock_data';
+
+const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
+  const Component = Vue.extend(groupFolderComponent);
+
+  return new Component({
+    propsData: {
+      groups,
+      parentGroup,
+    },
+  });
+};
+
+describe('GroupFolderComponent', () => {
+  let vm;
+
+  beforeEach((done) => {
+    Vue.component('group-item', groupItemComponent);
+
+    vm = createComponent();
+    vm.$mount();
+
+    Vue.nextTick(() => {
+      done();
+    });
+  });
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  describe('computed', () => {
+    describe('hasMoreChildren', () => {
+      it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
+        expect(vm.hasMoreChildren).toBeFalsy();
+      });
+    });
+
+    describe('moreChildrenStats', () => {
+      it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
+        expect(vm.moreChildrenStats).toBe('3 more items');
+      });
+    });
+  });
+
+  describe('template', () => {
+    it('should render component template correctly', () => {
+      expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
+      expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
+    });
+
+    it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
+      const parentGroup = Object.assign({}, mockParentGroupItem);
+      parentGroup.childrenCount = 21;
+
+      const newVm = createComponent(mockGroups, parentGroup);
+      newVm.$mount();
+      expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
+      newVm.$destroy();
+    });
+  });
+});
diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..0f4fbdae445a59f78492bee7322197ff3dedd0bf
--- /dev/null
+++ b/spec/javascripts/groups/components/group_item_spec.js
@@ -0,0 +1,177 @@
+import Vue from 'vue';
+
+import groupItemComponent from '~/groups/components/group_item.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+  const Component = Vue.extend(groupItemComponent);
+
+  return mountComponent(Component, {
+    group,
+    parentGroup,
+  });
+};
+
+describe('GroupItemComponent', () => {
+  let vm;
+
+  beforeEach((done) => {
+    Vue.component('group-folder', groupFolderComponent);
+
+    vm = createComponent();
+
+    Vue.nextTick(() => {
+      done();
+    });
+  });
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  describe('computed', () => {
+    describe('groupDomId', () => {
+      it('should return ID string suffixed with group ID', () => {
+        expect(vm.groupDomId).toBe('group-55');
+      });
+    });
+
+    describe('rowClass', () => {
+      it('should return map of classes based on group details', () => {
+        const classes = ['is-open', 'has-children', 'has-description', 'being-removed'];
+        const rowClass = vm.rowClass;
+
+        expect(Object.keys(rowClass).length).toBe(classes.length);
+        Object.keys(rowClass).forEach((className) => {
+          expect(classes.indexOf(className) > -1).toBeTruthy();
+        });
+      });
+    });
+
+    describe('hasChildren', () => {
+      it('should return boolean value representing if group has any children present', () => {
+        let newVm;
+        const group = Object.assign({}, mockParentGroupItem);
+
+        group.childrenCount = 5;
+        newVm = createComponent(group);
+        expect(newVm.hasChildren).toBeTruthy();
+        newVm.$destroy();
+
+        group.childrenCount = 0;
+        newVm = createComponent(group);
+        expect(newVm.hasChildren).toBeFalsy();
+        newVm.$destroy();
+      });
+    });
+
+    describe('hasAvatar', () => {
+      it('should return boolean value representing if group has any avatar present', () => {
+        let newVm;
+        const group = Object.assign({}, mockParentGroupItem);
+
+        group.avatarUrl = null;
+        newVm = createComponent(group);
+        expect(newVm.hasAvatar).toBeFalsy();
+        newVm.$destroy();
+
+        group.avatarUrl = '/uploads/group_avatar.png';
+        newVm = createComponent(group);
+        expect(newVm.hasAvatar).toBeTruthy();
+        newVm.$destroy();
+      });
+    });
+
+    describe('isGroup', () => {
+      it('should return boolean value representing if group item is of type `group` or not', () => {
+        let newVm;
+        const group = Object.assign({}, mockParentGroupItem);
+
+        group.type = 'group';
+        newVm = createComponent(group);
+        expect(newVm.isGroup).toBeTruthy();
+        newVm.$destroy();
+
+        group.type = 'project';
+        newVm = createComponent(group);
+        expect(newVm.isGroup).toBeFalsy();
+        newVm.$destroy();
+      });
+    });
+  });
+
+  describe('methods', () => {
+    describe('onClickRowGroup', () => {
+      let event;
+
+      beforeEach(() => {
+        const classList = {
+          contains() {
+            return false;
+          },
+        };
+
+        event = {
+          target: {
+            classList,
+            parentElement: {
+              classList,
+            },
+          },
+        };
+      });
+
+      it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => {
+        spyOn(eventHub, '$emit');
+
+        vm.onClickRowGroup(event);
+        expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group);
+      });
+
+      it('should navigate page to group homepage if group does not have any children present', (done) => {
+        const group = Object.assign({}, mockParentGroupItem);
+        group.childrenCount = 0;
+        const newVm = createComponent(group);
+        spyOn(gl.utils, 'visitUrl').and.stub();
+        spyOn(eventHub, '$emit');
+
+        newVm.onClickRowGroup(event);
+        setTimeout(() => {
+          expect(eventHub.$emit).not.toHaveBeenCalled();
+          expect(gl.utils.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
+          done();
+        }, 0);
+      });
+    });
+  });
+
+  describe('template', () => {
+    it('should render component template correctly', () => {
+      expect(vm.$el.getAttribute('id')).toBe('group-55');
+      expect(vm.$el.classList.contains('group-row')).toBeTruthy();
+
+      expect(vm.$el.querySelector('.group-row-contents')).toBeDefined();
+      expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined();
+      expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined();
+
+      expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined();
+      expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined();
+      expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined();
+
+      expect(vm.$el.querySelector('.avatar-container')).toBeDefined();
+      expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined();
+      expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined();
+
+      expect(vm.$el.querySelector('.title')).toBeDefined();
+      expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
+      expect(vm.$el.querySelector('.access-type')).toBeDefined();
+      expect(vm.$el.querySelector('.description')).toBeDefined();
+
+      expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+    });
+  });
+});
diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..90e818c1545baf99bb5ddc0455028128f8cbde71
--- /dev/null
+++ b/spec/javascripts/groups/components/groups_spec.js
@@ -0,0 +1,70 @@
+import Vue from 'vue';
+
+import groupsComponent from '~/groups/components/groups.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import { mockGroups, mockPageInfo } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (searchEmpty = false) => {
+  const Component = Vue.extend(groupsComponent);
+
+  return mountComponent(Component, {
+    groups: mockGroups,
+    pageInfo: mockPageInfo,
+    searchEmptyMessage: 'No matching results',
+    searchEmpty,
+  });
+};
+
+describe('GroupsComponent', () => {
+  let vm;
+
+  beforeEach((done) => {
+    Vue.component('group-folder', groupFolderComponent);
+    Vue.component('group-item', groupItemComponent);
+
+    vm = createComponent();
+
+    Vue.nextTick(() => {
+      done();
+    });
+  });
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  describe('methods', () => {
+    describe('change', () => {
+      it('should emit `fetchPage` event when page is changed via pagination', () => {
+        spyOn(eventHub, '$emit').and.stub();
+
+        vm.change(2);
+        expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', 2, jasmine.any(Object), jasmine.any(Object), jasmine.any(Object));
+      });
+    });
+  });
+
+  describe('template', () => {
+    it('should render component template correctly', (done) => {
+      Vue.nextTick(() => {
+        expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+        expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+        expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
+        expect(vm.$el.querySelectorAll('.has-no-search-results').length === 0).toBeTruthy();
+        done();
+      });
+    });
+
+    it('should render empty search message when `searchEmpty` is `true`', (done) => {
+      vm.searchEmpty = true;
+      Vue.nextTick(() => {
+        expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
+        done();
+      });
+    });
+  });
+});
diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..2ce1a749a9645ea88faf8b5be37aa3282493f37d
--- /dev/null
+++ b/spec/javascripts/groups/components/item_actions_spec.js
@@ -0,0 +1,110 @@
+import Vue from 'vue';
+
+import itemActionsComponent from '~/groups/components/item_actions.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+  const Component = Vue.extend(itemActionsComponent);
+
+  return mountComponent(Component, {
+    group,
+    parentGroup,
+  });
+};
+
+describe('ItemActionsComponent', () => {
+  let vm;
+
+  beforeEach(() => {
+    vm = createComponent();
+  });
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  describe('computed', () => {
+    describe('leaveConfirmationMessage', () => {
+      it('should return appropriate string for leave group confirmation', () => {
+        expect(vm.leaveConfirmationMessage).toBe('Are you sure you want to leave the "platform / hardware" group?');
+      });
+    });
+  });
+
+  describe('methods', () => {
+    describe('onLeaveGroup', () => {
+      it('should change `dialogStatus` prop to `true` which shows confirmation dialog', () => {
+        expect(vm.dialogStatus).toBeFalsy();
+        vm.onLeaveGroup();
+        expect(vm.dialogStatus).toBeTruthy();
+      });
+    });
+
+    describe('leaveGroup', () => {
+      it('should change `dialogStatus` prop to `false` and emit `leaveGroup` event with required params when called with `leaveConfirmed` as `true`', () => {
+        spyOn(eventHub, '$emit');
+        vm.dialogStatus = true;
+        vm.leaveGroup(true);
+        expect(vm.dialogStatus).toBeFalsy();
+        expect(eventHub.$emit).toHaveBeenCalledWith('leaveGroup', vm.group, vm.parentGroup);
+      });
+
+      it('should change `dialogStatus` prop to `false` and should NOT emit `leaveGroup` event when called with `leaveConfirmed` as `false`', () => {
+        spyOn(eventHub, '$emit');
+        vm.dialogStatus = true;
+        vm.leaveGroup(false);
+        expect(vm.dialogStatus).toBeFalsy();
+        expect(eventHub.$emit).not.toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('template', () => {
+    it('should render component template correctly', () => {
+      expect(vm.$el.classList.contains('controls')).toBeTruthy();
+    });
+
+    it('should render Edit Group button with correct attribute values', () => {
+      const group = Object.assign({}, mockParentGroupItem);
+      group.canEdit = true;
+      const newVm = createComponent(group);
+
+      const editBtn = newVm.$el.querySelector('a.edit-group');
+      expect(editBtn).toBeDefined();
+      expect(editBtn.classList.contains('no-expand')).toBeTruthy();
+      expect(editBtn.getAttribute('href')).toBe(group.editPath);
+      expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
+      expect(editBtn.dataset.originalTitle).toBe('Edit group');
+      expect(editBtn.querySelector('i.fa.fa-cogs')).toBeDefined();
+
+      newVm.$destroy();
+    });
+
+    it('should render Leave Group button with correct attribute values', () => {
+      const group = Object.assign({}, mockParentGroupItem);
+      group.canLeave = true;
+      const newVm = createComponent(group);
+
+      const leaveBtn = newVm.$el.querySelector('a.leave-group');
+      expect(leaveBtn).toBeDefined();
+      expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
+      expect(leaveBtn.getAttribute('href')).toBe(group.leavePath);
+      expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group');
+      expect(leaveBtn.dataset.originalTitle).toBe('Leave this group');
+      expect(leaveBtn.querySelector('i.fa.fa-sign-out')).toBeDefined();
+
+      newVm.$destroy();
+    });
+
+    it('should show modal dialog when `dialogStatus` is set to `true`', () => {
+      vm.dialogStatus = true;
+      const modalDialogEl = vm.$el.querySelector('.modal.popup-dialog');
+      expect(modalDialogEl).toBeDefined();
+      expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
+      expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
+    });
+  });
+});
diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4310a07e6e6e9a7b389cd004e5a8af66244b53af
--- /dev/null
+++ b/spec/javascripts/groups/components/item_caret_spec.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+
+import itemCaretComponent from '~/groups/components/item_caret.vue';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (isGroupOpen = false) => {
+  const Component = Vue.extend(itemCaretComponent);
+
+  return mountComponent(Component, {
+    isGroupOpen,
+  });
+};
+
+describe('ItemCaretComponent', () => {
+  describe('template', () => {
+    it('should render component template correctly', () => {
+      const vm = createComponent();
+      vm.$mount();
+      expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
+      vm.$destroy();
+    });
+
+    it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
+      const vm = createComponent(true);
+      vm.$mount();
+      expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(1);
+      expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(0);
+      vm.$destroy();
+    });
+
+    it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
+      const vm = createComponent();
+      vm.$mount();
+      expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(0);
+      expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(1);
+      vm.$destroy();
+    });
+  });
+});
diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..e200f9f08bd644e0b02947b49b79a3667b31715d
--- /dev/null
+++ b/spec/javascripts/groups/components/item_stats_spec.js
@@ -0,0 +1,159 @@
+import Vue from 'vue';
+
+import itemStatsComponent from '~/groups/components/item_stats.vue';
+import {
+  mockParentGroupItem,
+  ITEM_TYPE,
+  VISIBILITY_TYPE_ICON,
+  GROUP_VISIBILITY_TYPE,
+  PROJECT_VISIBILITY_TYPE,
+} from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (item = mockParentGroupItem) => {
+  const Component = Vue.extend(itemStatsComponent);
+
+  return mountComponent(Component, {
+    item,
+  });
+};
+
+describe('ItemStatsComponent', () => {
+  describe('computed', () => {
+    describe('visibilityIcon', () => {
+      it('should return icon class based on `item.visibility` value', () => {
+        Object.keys(VISIBILITY_TYPE_ICON).forEach((visibility) => {
+          const item = Object.assign({}, mockParentGroupItem, { visibility });
+          const vm = createComponent(item);
+          vm.$mount();
+          expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
+          vm.$destroy();
+        });
+      });
+    });
+
+    describe('visibilityTooltip', () => {
+      it('should return tooltip string for Group based on `item.visibility` value', () => {
+        Object.keys(GROUP_VISIBILITY_TYPE).forEach((visibility) => {
+          const item = Object.assign({}, mockParentGroupItem, {
+            visibility,
+            type: ITEM_TYPE.GROUP,
+          });
+          const vm = createComponent(item);
+          vm.$mount();
+          expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
+          vm.$destroy();
+        });
+      });
+
+      it('should return tooltip string for Project based on `item.visibility` value', () => {
+        Object.keys(PROJECT_VISIBILITY_TYPE).forEach((visibility) => {
+          const item = Object.assign({}, mockParentGroupItem, {
+            visibility,
+            type: ITEM_TYPE.PROJECT,
+          });
+          const vm = createComponent(item);
+          vm.$mount();
+          expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
+          vm.$destroy();
+        });
+      });
+    });
+
+    describe('isProject', () => {
+      it('should return boolean value representing whether `item.type` is Project or not', () => {
+        let item;
+        let vm;
+
+        item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
+        vm = createComponent(item);
+        vm.$mount();
+        expect(vm.isProject).toBeTruthy();
+        vm.$destroy();
+
+        item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+        vm = createComponent(item);
+        vm.$mount();
+        expect(vm.isProject).toBeFalsy();
+        vm.$destroy();
+      });
+    });
+
+    describe('isGroup', () => {
+      it('should return boolean value representing whether `item.type` is Group or not', () => {
+        let item;
+        let vm;
+
+        item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+        vm = createComponent(item);
+        vm.$mount();
+        expect(vm.isGroup).toBeTruthy();
+        vm.$destroy();
+
+        item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
+        vm = createComponent(item);
+        vm.$mount();
+        expect(vm.isGroup).toBeFalsy();
+        vm.$destroy();
+      });
+    });
+  });
+
+  describe('template', () => {
+    it('should render component template correctly', () => {
+      const vm = createComponent();
+      vm.$mount();
+
+      const visibilityIconEl = vm.$el.querySelector('.item-visibility');
+      expect(vm.$el.classList.contains('.stats')).toBeDefined();
+      expect(visibilityIconEl).toBeDefined();
+      expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
+      expect(visibilityIconEl.querySelector('i.fa')).toBeDefined();
+
+      vm.$destroy();
+    });
+
+    it('should render stat icons if `item.type` is Group', () => {
+      const item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+      const vm = createComponent(item);
+      vm.$mount();
+
+      const subgroupIconEl = vm.$el.querySelector('span.number-subgroups');
+      expect(subgroupIconEl).toBeDefined();
+      expect(subgroupIconEl.dataset.originalTitle).toBe('Subgroups');
+      expect(subgroupIconEl.querySelector('i.fa.fa-folder')).toBeDefined();
+      expect(subgroupIconEl.innerText.trim()).toBe(`${vm.item.subgroupCount}`);
+
+      const projectsIconEl = vm.$el.querySelector('span.number-projects');
+      expect(projectsIconEl).toBeDefined();
+      expect(projectsIconEl.dataset.originalTitle).toBe('Projects');
+      expect(projectsIconEl.querySelector('i.fa.fa-bookmark')).toBeDefined();
+      expect(projectsIconEl.innerText.trim()).toBe(`${vm.item.projectCount}`);
+
+      const membersIconEl = vm.$el.querySelector('span.number-users');
+      expect(membersIconEl).toBeDefined();
+      expect(membersIconEl.dataset.originalTitle).toBe('Members');
+      expect(membersIconEl.querySelector('i.fa.fa-users')).toBeDefined();
+      expect(membersIconEl.innerText.trim()).toBe(`${vm.item.memberCount}`);
+
+      vm.$destroy();
+    });
+
+    it('should render stat icons if `item.type` is Project', () => {
+      const item = Object.assign({}, mockParentGroupItem, {
+        type: ITEM_TYPE.PROJECT,
+        starCount: 4,
+      });
+      const vm = createComponent(item);
+      vm.$mount();
+
+      const projectStarIconEl = vm.$el.querySelector('.project-stars');
+      expect(projectStarIconEl).toBeDefined();
+      expect(projectStarIconEl.querySelector('i.fa.fa-star')).toBeDefined();
+      expect(projectStarIconEl.innerText.trim()).toBe(`${vm.item.starCount}`);
+
+      vm.$destroy();
+    });
+  });
+});
diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..528e6ed1b4c471825c74f8fbcd8b7bf6967b4e6b
--- /dev/null
+++ b/spec/javascripts/groups/components/item_type_icon_spec.js
@@ -0,0 +1,54 @@
+import Vue from 'vue';
+
+import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import { ITEM_TYPE } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
+  const Component = Vue.extend(itemTypeIconComponent);
+
+  return mountComponent(Component, {
+    itemType,
+    isGroupOpen,
+  });
+};
+
+describe('ItemTypeIconComponent', () => {
+  describe('template', () => {
+    it('should render component template correctly', () => {
+      const vm = createComponent();
+      vm.$mount();
+      expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
+      vm.$destroy();
+    });
+
+    it('should render folder open or close icon based `isGroupOpen` prop value', () => {
+      let vm;
+
+      vm = createComponent(ITEM_TYPE.GROUP, true);
+      vm.$mount();
+      expect(vm.$el.querySelector('i.fa.fa-folder-open')).toBeDefined();
+      vm.$destroy();
+
+      vm = createComponent(ITEM_TYPE.GROUP);
+      vm.$mount();
+      expect(vm.$el.querySelector('i.fa.fa-folder')).toBeDefined();
+      vm.$destroy();
+    });
+
+    it('should render bookmark icon based on `isProject` prop value', () => {
+      let vm;
+
+      vm = createComponent(ITEM_TYPE.PROJECT);
+      vm.$mount();
+      expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(1);
+      vm.$destroy();
+
+      vm = createComponent(ITEM_TYPE.GROUP);
+      vm.$mount();
+      expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(0);
+      vm.$destroy();
+    });
+  });
+});
diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js
deleted file mode 100644
index 25e10552d95b2e3c666ea26d3b54bd4044c72610..0000000000000000000000000000000000000000
--- a/spec/javascripts/groups/group_item_spec.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import Vue from 'vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import GroupsStore from '~/groups/stores/groups_store';
-import { group1 } from './mock_data';
-
-describe('Groups Component', () => {
-  let GroupItemComponent;
-  let component;
-  let store;
-  let group;
-
-  describe('group with default data', () => {
-    beforeEach((done) => {
-      GroupItemComponent = Vue.extend(groupItemComponent);
-      store = new GroupsStore();
-      group = store.decorateGroup(group1);
-
-      component = new GroupItemComponent({
-        propsData: {
-          group,
-        },
-      }).$mount();
-
-      Vue.nextTick(() => {
-        done();
-      });
-    });
-
-    afterEach(() => {
-      component.$destroy();
-    });
-
-    it('should render the group item correctly', () => {
-      expect(component.$el.classList.contains('group-row')).toBe(true);
-      expect(component.$el.classList.contains('.no-description')).toBe(false);
-      expect(component.$el.querySelector('.number-projects').textContent).toContain(group.numberProjects);
-      expect(component.$el.querySelector('.number-users').textContent).toContain(group.numberUsers);
-      expect(component.$el.querySelector('.group-visibility')).toBeDefined();
-      expect(component.$el.querySelector('.avatar-container')).toBeDefined();
-      expect(component.$el.querySelector('.title').textContent).toContain(group.name);
-      expect(component.$el.querySelector('.access-type').textContent).toContain(group.permissions.humanGroupAccess);
-      expect(component.$el.querySelector('.description').textContent).toContain(group.description);
-      expect(component.$el.querySelector('.edit-group')).toBeDefined();
-      expect(component.$el.querySelector('.leave-group')).toBeDefined();
-    });
-  });
-
-  describe('group without description', () => {
-    beforeEach((done) => {
-      GroupItemComponent = Vue.extend(groupItemComponent);
-      store = new GroupsStore();
-      group1.description = '';
-      group = store.decorateGroup(group1);
-
-      component = new GroupItemComponent({
-        propsData: {
-          group,
-        },
-      }).$mount();
-
-      Vue.nextTick(() => {
-        done();
-      });
-    });
-
-    afterEach(() => {
-      component.$destroy();
-    });
-
-    it('should render group item correctly', () => {
-      expect(component.$el.querySelector('.description').textContent).toBe('');
-      expect(component.$el.classList.contains('.no-description')).toBe(false);
-    });
-  });
-
-  describe('user has not access to group', () => {
-    beforeEach((done) => {
-      GroupItemComponent = Vue.extend(groupItemComponent);
-      store = new GroupsStore();
-      group1.permissions.human_group_access = null;
-      group = store.decorateGroup(group1);
-
-      component = new GroupItemComponent({
-        propsData: {
-          group,
-        },
-      }).$mount();
-
-      Vue.nextTick(() => {
-        done();
-      });
-    });
-
-    afterEach(() => {
-      component.$destroy();
-    });
-
-    it('should not display access type', () => {
-      expect(component.$el.querySelector('.access-type')).toBeNull();
-    });
-  });
-});
diff --git a/spec/javascripts/groups/groups_spec.js b/spec/javascripts/groups/groups_spec.js
deleted file mode 100644
index b14153dbbfa4cb8221407e96784ea0cca830e808..0000000000000000000000000000000000000000
--- a/spec/javascripts/groups/groups_spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import Vue from 'vue';
-import eventHub from '~/groups/event_hub';
-import groupFolderComponent from '~/groups/components/group_folder.vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import groupsComponent from '~/groups/components/groups.vue';
-import GroupsStore from '~/groups/stores/groups_store';
-import { groupsData } from './mock_data';
-
-describe('Groups Component', () => {
-  let GroupsComponent;
-  let store;
-  let component;
-  let groups;
-
-  beforeEach((done) => {
-    Vue.component('group-folder', groupFolderComponent);
-    Vue.component('group-item', groupItemComponent);
-
-    store = new GroupsStore();
-    groups = store.setGroups(groupsData.groups);
-
-    store.storePagination(groupsData.pagination);
-
-    GroupsComponent = Vue.extend(groupsComponent);
-
-    component = new GroupsComponent({
-      propsData: {
-        groups: store.state.groups,
-        pageInfo: store.state.pageInfo,
-      },
-    }).$mount();
-
-    Vue.nextTick(() => {
-      done();
-    });
-  });
-
-  afterEach(() => {
-    component.$destroy();
-  });
-
-  describe('with data', () => {
-    it('should render a list of groups', () => {
-      expect(component.$el.classList.contains('groups-list-tree-container')).toBe(true);
-      expect(component.$el.querySelector('#group-12')).toBeDefined();
-      expect(component.$el.querySelector('#group-1119')).toBeDefined();
-      expect(component.$el.querySelector('#group-1120')).toBeDefined();
-    });
-
-    it('should respect the order of groups', () => {
-      const wrap = component.$el.querySelector('.groups-list-tree-container > .group-list-tree');
-      expect(wrap.querySelector('.group-row:nth-child(1)').id).toBe('group-12');
-      expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119');
-    });
-
-    it('should render group and its subgroup', () => {
-      const lists = component.$el.querySelectorAll('.group-list-tree');
-
-      expect(lists.length).toBe(3); // one parent and two subgroups
-
-      expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBe(true);
-      expect(lists[0].querySelector('#group-1119').classList.contains('has-subgroups')).toBe(true);
-
-      expect(lists[2].querySelector('#group-1120').textContent).toContain(groups.id1119.subGroups.id1120.name);
-    });
-
-    it('should render group identicon when group avatar is not present', () => {
-      const avatar = component.$el.querySelector('#group-12 .avatar-container .avatar');
-      expect(avatar.nodeName).toBe('DIV');
-      expect(avatar.classList.contains('identicon')).toBeTruthy();
-      expect(avatar.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
-    });
-
-    it('should render group avatar when group avatar is present', () => {
-      const avatar = component.$el.querySelector('#group-1120 .avatar-container .avatar');
-      expect(avatar.nodeName).toBe('IMG');
-      expect(avatar.classList.contains('identicon')).toBeFalsy();
-    });
-
-    it('should remove prefix of parent group', () => {
-      expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4');
-    });
-
-    it('should remove the group after leaving the group', (done) => {
-      spyOn(window, 'confirm').and.returnValue(true);
-
-      eventHub.$on('leaveGroup', (group, collection) => {
-        store.removeGroup(group, collection);
-      });
-
-      component.$el.querySelector('#group-12 .leave-group').click();
-
-      Vue.nextTick(() => {
-        expect(component.$el.querySelector('#group-12')).toBeNull();
-        done();
-      });
-    });
-  });
-});
diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js
index 5bb84b591f4a4aef7304514f0d0d8c006457680b..6184d6717903fa4acf5880d4b8d938a915aadb10 100644
--- a/spec/javascripts/groups/mock_data.js
+++ b/spec/javascripts/groups/mock_data.js
@@ -1,114 +1,380 @@
-const group1 = {
-  id: 12,
-  name: 'level1',
-  path: 'level1',
-  description: 'foo',
-  visibility: 'public',
-  avatar_url: null,
-  web_url: 'http://localhost:3000/groups/level1',
-  group_path: '/level1',
-  full_name: 'level1',
-  full_path: 'level1',
-  parent_id: null,
-  created_at: '2017-05-15T19:01:23.670Z',
-  updated_at: '2017-05-15T19:01:23.670Z',
-  number_projects_with_delimiter: '1',
-  number_users_with_delimiter: '1',
-  has_subgroups: true,
-  permissions: {
-    human_group_access: 'Master',
-  },
+export const mockEndpoint = '/dashboard/groups.json';
+
+export const ITEM_TYPE = {
+  PROJECT: 'project',
+  GROUP: 'group',
 };
 
-// This group has no direct parent, should be placed as subgroup of group1
-const group14 = {
-  id: 1128,
-  name: 'level4',
-  path: 'level4',
-  description: 'foo',
-  visibility: 'public',
-  avatar_url: null,
-  web_url: 'http://localhost:3000/groups/level1/level2/level3/level4',
-  group_path: '/level1/level2/level3/level4',
-  full_name: 'level1 / level2 / level3 / level4',
-  full_path: 'level1/level2/level3/level4',
-  parent_id: 1127,
-  created_at: '2017-05-15T19:02:01.645Z',
-  updated_at: '2017-05-15T19:02:01.645Z',
-  number_projects_with_delimiter: '1',
-  number_users_with_delimiter: '1',
-  has_subgroups: true,
-  permissions: {
-    human_group_access: 'Master',
-  },
+export const GROUP_VISIBILITY_TYPE = {
+  public: 'Public - The group and any public projects can be viewed without any authentication.',
+  internal: 'Internal - The group and any internal projects can be viewed by any logged in user.',
+  private: 'Private - The group and its projects can only be viewed by members.',
 };
 
-const group2 = {
-  id: 1119,
-  name: 'devops',
-  path: 'devops',
-  description: 'foo',
-  visibility: 'public',
-  avatar_url: null,
-  web_url: 'http://localhost:3000/groups/devops',
-  group_path: '/devops',
-  full_name: 'devops',
-  full_path: 'devops',
-  parent_id: null,
-  created_at: '2017-05-11T19:35:09.635Z',
-  updated_at: '2017-05-11T19:35:09.635Z',
-  number_projects_with_delimiter: '1',
-  number_users_with_delimiter: '1',
-  has_subgroups: true,
-  permissions: {
-    human_group_access: 'Master',
-  },
+export const PROJECT_VISIBILITY_TYPE = {
+  public: 'Public - The project can be accessed without any authentication.',
+  internal: 'Internal - The project can be accessed by any logged in user.',
+  private: 'Private - Project access must be granted explicitly to each user.',
+};
+
+export const VISIBILITY_TYPE_ICON = {
+  public: 'fa-globe',
+  internal: 'fa-shield',
+  private: 'fa-lock',
 };
 
-const group21 = {
-  id: 1120,
-  name: 'chef',
-  path: 'chef',
-  description: 'foo',
+export const mockParentGroupItem = {
+  id: 55,
+  name: 'hardware',
+  description: '',
   visibility: 'public',
-  avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
-  web_url: 'http://localhost:3000/groups/devops/chef',
-  group_path: '/devops/chef',
-  full_name: 'devops / chef',
-  full_path: 'devops/chef',
-  parent_id: 1119,
-  created_at: '2017-05-11T19:51:04.060Z',
-  updated_at: '2017-05-11T19:51:04.060Z',
-  number_projects_with_delimiter: '1',
-  number_users_with_delimiter: '1',
-  has_subgroups: true,
-  permissions: {
-    human_group_access: 'Master',
-  },
+  fullName: 'platform / hardware',
+  relativePath: '/platform/hardware',
+  canEdit: true,
+  type: 'group',
+  avatarUrl: null,
+  permission: 'Owner',
+  editPath: '/groups/platform/hardware/edit',
+  childrenCount: 3,
+  leavePath: '/groups/platform/hardware/group_members/leave',
+  parentId: 54,
+  memberCount: '1',
+  projectCount: 1,
+  subgroupCount: 2,
+  canLeave: false,
+  children: [],
+  isOpen: true,
+  isChildrenLoading: false,
+  isBeingRemoved: false,
 };
 
-const groupsData = {
-  groups: [group1, group14, group2, group21],
-  pagination: {
-    Date: 'Mon, 22 May 2017 22:31:52 GMT',
-    'X-Prev-Page': '1',
-    'X-Content-Type-Options': 'nosniff',
-    'X-Total': '31',
-    'Transfer-Encoding': 'chunked',
-    'X-Runtime': '0.611144',
-    'X-Xss-Protection': '1; mode=block',
-    'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09',
-    'X-Ua-Compatible': 'IE=edge',
-    'X-Per-Page': '20',
-    Link: '<http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="prev", <http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="first", <http://localhost:3000/dashboard/groups.json?page=2&per_page=20>; rel="last"',
-    'X-Next-Page': '',
-    Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"',
-    'X-Frame-Options': 'DENY',
-    'Content-Type': 'application/json; charset=utf-8',
-    'Cache-Control': 'max-age=0, private, must-revalidate',
-    'X-Total-Pages': '2',
-    'X-Page': '2',
+export const mockRawChildren = [
+  {
+    id: 57,
+    name: 'bsp',
+    description: '',
+    visibility: 'public',
+    full_name: 'platform / hardware / bsp',
+    relative_path: '/platform/hardware/bsp',
+    can_edit: true,
+    type: 'group',
+    avatar_url: null,
+    permission: 'Owner',
+    edit_path: '/groups/platform/hardware/bsp/edit',
+    children_count: 6,
+    leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+    parent_id: 55,
+    number_users_with_delimiter: '1',
+    project_count: 4,
+    subgroup_count: 2,
+    can_leave: false,
+    children: [],
+  },
+];
+
+export const mockChildren = [
+  {
+    id: 57,
+    name: 'bsp',
+    description: '',
+    visibility: 'public',
+    fullName: 'platform / hardware / bsp',
+    relativePath: '/platform/hardware/bsp',
+    canEdit: true,
+    type: 'group',
+    avatarUrl: null,
+    permission: 'Owner',
+    editPath: '/groups/platform/hardware/bsp/edit',
+    childrenCount: 6,
+    leavePath: '/groups/platform/hardware/bsp/group_members/leave',
+    parentId: 55,
+    memberCount: '1',
+    projectCount: 4,
+    subgroupCount: 2,
+    canLeave: false,
+    children: [],
+    isOpen: true,
+    isChildrenLoading: false,
+    isBeingRemoved: false,
   },
+];
+
+export const mockGroups = [
+  {
+    id: 75,
+    name: 'test-group',
+    description: '',
+    visibility: 'public',
+    full_name: 'test-group',
+    relative_path: '/test-group',
+    can_edit: true,
+    type: 'group',
+    avatar_url: null,
+    permission: 'Owner',
+    edit_path: '/groups/test-group/edit',
+    children_count: 2,
+    leave_path: '/groups/test-group/group_members/leave',
+    parent_id: null,
+    number_users_with_delimiter: '1',
+    project_count: 2,
+    subgroup_count: 0,
+    can_leave: false,
+  },
+  {
+    id: 67,
+    name: 'open-source',
+    description: '',
+    visibility: 'private',
+    full_name: 'open-source',
+    relative_path: '/open-source',
+    can_edit: true,
+    type: 'group',
+    avatar_url: null,
+    permission: 'Owner',
+    edit_path: '/groups/open-source/edit',
+    children_count: 0,
+    leave_path: '/groups/open-source/group_members/leave',
+    parent_id: null,
+    number_users_with_delimiter: '1',
+    project_count: 0,
+    subgroup_count: 0,
+    can_leave: false,
+  },
+  {
+    id: 54,
+    name: 'platform',
+    description: '',
+    visibility: 'public',
+    full_name: 'platform',
+    relative_path: '/platform',
+    can_edit: true,
+    type: 'group',
+    avatar_url: null,
+    permission: 'Owner',
+    edit_path: '/groups/platform/edit',
+    children_count: 1,
+    leave_path: '/groups/platform/group_members/leave',
+    parent_id: null,
+    number_users_with_delimiter: '1',
+    project_count: 0,
+    subgroup_count: 1,
+    can_leave: false,
+  },
+  {
+    id: 5,
+    name: 'H5bp',
+    description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.',
+    visibility: 'public',
+    full_name: 'H5bp',
+    relative_path: '/h5bp',
+    can_edit: true,
+    type: 'group',
+    avatar_url: null,
+    permission: 'Owner',
+    edit_path: '/groups/h5bp/edit',
+    children_count: 1,
+    leave_path: '/groups/h5bp/group_members/leave',
+    parent_id: null,
+    number_users_with_delimiter: '5',
+    project_count: 1,
+    subgroup_count: 0,
+    can_leave: false,
+  },
+  {
+    id: 4,
+    name: 'Twitter',
+    description: 'Deserunt hic nostrum placeat veniam.',
+    visibility: 'public',
+    full_name: 'Twitter',
+    relative_path: '/twitter',
+    can_edit: true,
+    type: 'group',
+    avatar_url: null,
+    permission: 'Owner',
+    edit_path: '/groups/twitter/edit',
+    children_count: 2,
+    leave_path: '/groups/twitter/group_members/leave',
+    parent_id: null,
+    number_users_with_delimiter: '5',
+    project_count: 2,
+    subgroup_count: 0,
+    can_leave: false,
+  },
+  {
+    id: 3,
+    name: 'Documentcloud',
+    description: 'Consequatur saepe totam ea pariatur maxime.',
+    visibility: 'public',
+    full_name: 'Documentcloud',
+    relative_path: '/documentcloud',
+    can_edit: true,
+    type: 'group',
+    avatar_url: null,
+    permission: 'Owner',
+    edit_path: '/groups/documentcloud/edit',
+    children_count: 1,
+    leave_path: '/groups/documentcloud/group_members/leave',
+    parent_id: null,
+    number_users_with_delimiter: '5',
+    project_count: 1,
+    subgroup_count: 0,
+    can_leave: false,
+  },
+  {
+    id: 2,
+    name: 'Gitlab Org',
+    description: 'Debitis ea quas aperiam velit doloremque ab.',
+    visibility: 'public',
+    full_name: 'Gitlab Org',
+    relative_path: '/gitlab-org',
+    can_edit: true,
+    type: 'group',
+    avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
+    permission: 'Owner',
+    edit_path: '/groups/gitlab-org/edit',
+    children_count: 4,
+    leave_path: '/groups/gitlab-org/group_members/leave',
+    parent_id: null,
+    number_users_with_delimiter: '5',
+    project_count: 4,
+    subgroup_count: 0,
+    can_leave: false,
+  },
+];
+
+export const mockSearchedGroups = [
+  {
+    id: 55,
+    name: 'hardware',
+    description: '',
+    visibility: 'public',
+    full_name: 'platform / hardware',
+    relative_path: '/platform/hardware',
+    can_edit: true,
+    type: 'group',
+    avatar_url: null,
+    permission: 'Owner',
+    edit_path: '/groups/platform/hardware/edit',
+    children_count: 3,
+    leave_path: '/groups/platform/hardware/group_members/leave',
+    parent_id: 54,
+    number_users_with_delimiter: '1',
+    project_count: 1,
+    subgroup_count: 2,
+    can_leave: false,
+    children: [
+      {
+        id: 57,
+        name: 'bsp',
+        description: '',
+        visibility: 'public',
+        full_name: 'platform / hardware / bsp',
+        relative_path: '/platform/hardware/bsp',
+        can_edit: true,
+        type: 'group',
+        avatar_url: null,
+        permission: 'Owner',
+        edit_path: '/groups/platform/hardware/bsp/edit',
+        children_count: 6,
+        leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+        parent_id: 55,
+        number_users_with_delimiter: '1',
+        project_count: 4,
+        subgroup_count: 2,
+        can_leave: false,
+        children: [
+          {
+            id: 60,
+            name: 'kernel',
+            description: '',
+            visibility: 'public',
+            full_name: 'platform / hardware / bsp / kernel',
+            relative_path: '/platform/hardware/bsp/kernel',
+            can_edit: true,
+            type: 'group',
+            avatar_url: null,
+            permission: 'Owner',
+            edit_path: '/groups/platform/hardware/bsp/kernel/edit',
+            children_count: 1,
+            leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave',
+            parent_id: 57,
+            number_users_with_delimiter: '1',
+            project_count: 0,
+            subgroup_count: 1,
+            can_leave: false,
+            children: [
+              {
+                id: 61,
+                name: 'common',
+                description: '',
+                visibility: 'public',
+                full_name: 'platform / hardware / bsp / kernel / common',
+                relative_path: '/platform/hardware/bsp/kernel/common',
+                can_edit: true,
+                type: 'group',
+                avatar_url: null,
+                permission: 'Owner',
+                edit_path: '/groups/platform/hardware/bsp/kernel/common/edit',
+                children_count: 2,
+                leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave',
+                parent_id: 60,
+                number_users_with_delimiter: '1',
+                project_count: 2,
+                subgroup_count: 0,
+                can_leave: false,
+                children: [
+                  {
+                    id: 17,
+                    name: 'v4.4',
+                    description: 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.',
+                    visibility: 'public',
+                    full_name: 'platform / hardware / bsp / kernel / common / v4.4',
+                    relative_path: '/platform/hardware/bsp/kernel/common/v4.4',
+                    can_edit: true,
+                    type: 'project',
+                    avatar_url: null,
+                    permission: null,
+                    edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit',
+                    star_count: 0,
+                  },
+                  {
+                    id: 16,
+                    name: 'v4.1',
+                    description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.',
+                    visibility: 'public',
+                    full_name: 'platform / hardware / bsp / kernel / common / v4.1',
+                    relative_path: '/platform/hardware/bsp/kernel/common/v4.1',
+                    can_edit: true,
+                    type: 'project',
+                    avatar_url: null,
+                    permission: null,
+                    edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit',
+                    star_count: 0,
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export const mockRawPageInfo = {
+  'x-per-page': 10,
+  'x-page': 10,
+  'x-total': 10,
+  'x-total-pages': 10,
+  'x-next-page': 10,
+  'x-prev-page': 10,
 };
 
-export { groupsData, group1 };
+export const mockPageInfo = {
+  perPage: 10,
+  page: 10,
+  total: 10,
+  totalPages: 10,
+  nextPage: 10,
+  prevPage: 10,
+};
diff --git a/spec/javascripts/groups/service/groups_service_spec.js b/spec/javascripts/groups/service/groups_service_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..20bb63687f7deb6cd60d4e3605ab55b2275fbd6f
--- /dev/null
+++ b/spec/javascripts/groups/service/groups_service_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+import GroupsService from '~/groups/service/groups_service';
+import { mockEndpoint, mockParentGroupItem } from '../mock_data';
+
+Vue.use(VueResource);
+
+describe('GroupsService', () => {
+  let service;
+
+  beforeEach(() => {
+    service = new GroupsService(mockEndpoint);
+  });
+
+  describe('getGroups', () => {
+    it('should return promise for `GET` request on provided endpoint', () => {
+      spyOn(service.groups, 'get').and.stub();
+      const queryParams = {
+        page: 2,
+        filter: 'git',
+        sort: 'created_asc',
+        archived: true,
+      };
+
+      service.getGroups(55, 2, 'git', 'created_asc', true);
+      expect(service.groups.get).toHaveBeenCalledWith({ parent_id: 55 });
+
+      service.getGroups(null, 2, 'git', 'created_asc', true);
+      expect(service.groups.get).toHaveBeenCalledWith(queryParams);
+    });
+  });
+
+  describe('leaveGroup', () => {
+    it('should return promise for `DELETE` request on provided endpoint', () => {
+      spyOn(Vue.http, 'delete').and.stub();
+
+      service.leaveGroup(mockParentGroupItem.leavePath);
+      expect(Vue.http.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
+    });
+  });
+});
diff --git a/spec/javascripts/groups/store/groups_store_spec.js b/spec/javascripts/groups/store/groups_store_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..d74f38f476e9c58f580838f7168a9ab06362ceb6
--- /dev/null
+++ b/spec/javascripts/groups/store/groups_store_spec.js
@@ -0,0 +1,110 @@
+import GroupsStore from '~/groups/store/groups_store';
+import {
+  mockGroups, mockSearchedGroups,
+  mockParentGroupItem, mockRawChildren,
+  mockRawPageInfo,
+} from '../mock_data';
+
+describe('ProjectsStore', () => {
+  describe('constructor', () => {
+    it('should initialize default state', () => {
+      let store;
+
+      store = new GroupsStore();
+      expect(Object.keys(store.state).length).toBe(2);
+      expect(Array.isArray(store.state.groups)).toBeTruthy();
+      expect(Object.keys(store.state.pageInfo).length).toBe(0);
+      expect(store.hideProjects).not.toBeDefined();
+
+      store = new GroupsStore(true);
+      expect(store.hideProjects).toBeTruthy();
+    });
+  });
+
+  describe('setGroups', () => {
+    it('should set groups to state', () => {
+      const store = new GroupsStore();
+      spyOn(store, 'formatGroupItem').and.callThrough();
+
+      store.setGroups(mockGroups);
+      expect(store.state.groups.length).toBe(mockGroups.length);
+      expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+      expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
+    });
+  });
+
+  describe('setSearchedGroups', () => {
+    it('should set searched groups to state', () => {
+      const store = new GroupsStore();
+      spyOn(store, 'formatGroupItem').and.callThrough();
+
+      store.setSearchedGroups(mockSearchedGroups);
+      expect(store.state.groups.length).toBe(mockSearchedGroups.length);
+      expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+      expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
+      expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName') > -1).toBeTruthy();
+    });
+  });
+
+  describe('setGroupChildren', () => {
+    it('should set children to group item in state', () => {
+      const store = new GroupsStore();
+      spyOn(store, 'formatGroupItem').and.callThrough();
+
+      store.setGroupChildren(mockParentGroupItem, mockRawChildren);
+      expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+      expect(mockParentGroupItem.children.length).toBe(1);
+      expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName') > -1).toBeTruthy();
+      expect(mockParentGroupItem.isOpen).toBeTruthy();
+      expect(mockParentGroupItem.isChildrenLoading).toBeFalsy();
+    });
+  });
+
+  describe('setPaginationInfo', () => {
+    it('should parse and set pagination info in state', () => {
+      const store = new GroupsStore();
+
+      store.setPaginationInfo(mockRawPageInfo);
+      expect(store.state.pageInfo.perPage).toBe(10);
+      expect(store.state.pageInfo.page).toBe(10);
+      expect(store.state.pageInfo.total).toBe(10);
+      expect(store.state.pageInfo.totalPages).toBe(10);
+      expect(store.state.pageInfo.nextPage).toBe(10);
+      expect(store.state.pageInfo.previousPage).toBe(10);
+    });
+  });
+
+  describe('formatGroupItem', () => {
+    it('should parse group item object and return updated object', () => {
+      let store;
+      let updatedGroupItem;
+
+      store = new GroupsStore();
+      updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+      expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
+      expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
+      expect(updatedGroupItem.isChildrenLoading).toBe(false);
+      expect(updatedGroupItem.isBeingRemoved).toBe(false);
+
+      store = new GroupsStore(true);
+      updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+      expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
+      expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
+    });
+  });
+
+  describe('removeGroup', () => {
+    it('should remove children from group item in state', () => {
+      const store = new GroupsStore();
+      const rawParentGroup = Object.assign({}, mockGroups[0]);
+      const rawChildGroup = Object.assign({}, mockGroups[1]);
+
+      store.setGroups([rawParentGroup]);
+      store.setGroupChildren(store.state.groups[0], [rawChildGroup]);
+      const childItem = store.state.groups[0].children[0];
+
+      store.removeGroup(childItem, store.state.groups[0]);
+      expect(store.state.groups[0].children.length).toBe(0);
+    });
+  });
+});
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 583a3a74d7769c5b6a8e83cacc5d46ee8d330eeb..2ea290108a4cee331ec3f1d9294b698f53d4405d 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -332,4 +332,15 @@ describe('Issuable output', () => {
         .catch(done.fail);
     });
   });
+
+  describe('show inline edit button', () => {
+    it('should not render by default', () => {
+      expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
+    });
+
+    it('should render if showInlineEditButton', () => {
+      vm.showInlineEditButton = true;
+      expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
+    });
+  });
 });
diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js
index a2d90a9b9f56709c8d59dcc567d6fe7e37c17c3c..c1edc785d0fe1e85b90a8c9819e3fb025d0c3afa 100644
--- a/spec/javascripts/issue_show/components/title_spec.js
+++ b/spec/javascripts/issue_show/components/title_spec.js
@@ -1,6 +1,7 @@
 import Vue from 'vue';
 import Store from '~/issue_show/stores';
 import titleComponent from '~/issue_show/components/title.vue';
+import eventHub from '~/issue_show/event_hub';
 
 describe('Title component', () => {
   let vm;
@@ -25,7 +26,7 @@ describe('Title component', () => {
 
   it('renders title HTML', () => {
     expect(
-      vm.$el.innerHTML.trim(),
+      vm.$el.querySelector('.title').innerHTML.trim(),
     ).toBe('Testing <img>');
   });
 
@@ -47,12 +48,12 @@ describe('Title component', () => {
 
     Vue.nextTick(() => {
       expect(
-        vm.$el.classList.contains('issue-realtime-pre-pulse'),
+        vm.$el.querySelector('.title').classList.contains('issue-realtime-pre-pulse'),
       ).toBeTruthy();
 
       setTimeout(() => {
         expect(
-          vm.$el.classList.contains('issue-realtime-trigger-pulse'),
+          vm.$el.querySelector('.title').classList.contains('issue-realtime-trigger-pulse'),
         ).toBeTruthy();
 
         done();
@@ -72,4 +73,36 @@ describe('Title component', () => {
       done();
     });
   });
+
+  describe('inline edit button', () => {
+    beforeEach(() => {
+      spyOn(eventHub, '$emit');
+    });
+
+    it('should not show by default', () => {
+      expect(vm.$el.querySelector('.note-action-button')).toBeNull();
+    });
+
+    it('should not show if canUpdate is false', () => {
+      vm.showInlineEditButton = true;
+      vm.canUpdate = false;
+      expect(vm.$el.querySelector('.note-action-button')).toBeNull();
+    });
+
+    it('should show if showInlineEditButton and canUpdate', () => {
+      vm.showInlineEditButton = true;
+      vm.canUpdate = true;
+      expect(vm.$el.querySelector('.note-action-button')).toBeDefined();
+    });
+
+    it('should trigger open.form event when clicked', () => {
+      vm.showInlineEditButton = true;
+      vm.canUpdate = true;
+
+      Vue.nextTick(() => {
+        vm.$el.querySelector('.note-action-button').click();
+        expect(eventHub.$emit).toHaveBeenCalledWith('open.form');
+      });
+    });
+  });
 });
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index f86f2f260c39ac8ca1f7e837b3a0b9df3aec2a35..a5298be56690db19d235420c98ca9e4c7073c213 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -467,15 +467,27 @@ describe('common_utils', () => {
       commonUtils.ajaxPost(requestURL, data);
       expect(ajaxSpy.calls.allArgs()[0][0].type).toEqual('POST');
     });
+  });
 
-    describe('gl.utils.spriteIcon', () => {
-      beforeEach(() => {
-        window.gon.sprite_icons = 'icons.svg';
-      });
+  describe('spriteIcon', () => {
+    let beforeGon;
 
-      it('should return the svg for a linked icon', () => {
-        expect(gl.utils.spriteIcon('test')).toEqual('<svg><use xlink:href="icons.svg#test" /></svg>');
-      });
+    beforeEach(() => {
+      window.gon = window.gon || {};
+      beforeGon = Object.assign({}, window.gon);
+      window.gon.sprite_icons = 'icons.svg';
+    });
+
+    afterEach(() => {
+      window.gon = beforeGon;
+    });
+
+    it('should return the svg for a linked icon', () => {
+      expect(commonUtils.spriteIcon('test')).toEqual('<svg ><use xlink:href="icons.svg#test" /></svg>');
+    });
+
+    it('should set svg className when passed', () => {
+      expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual('<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>');
     });
   });
 });
diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js
index 0635de4b30b13de587cb7a21166bfa4d4de404f0..e09d593f04cab52957376d7568c88eb201426ae7 100644
--- a/spec/javascripts/repo/components/repo_commit_section_spec.js
+++ b/spec/javascripts/repo/components/repo_commit_section_spec.js
@@ -134,6 +134,7 @@ describe('RepoCommitSection', () => {
     afterEach(() => {
       vm.$destroy();
       el.remove();
+      RepoStore.openedFiles = [];
     });
 
     it('shows commit message', () => {
diff --git a/spec/javascripts/repo/components/repo_edit_button_spec.js b/spec/javascripts/repo/components/repo_edit_button_spec.js
index 411514009dcffb90270ade976a2e774234ac5ff5..dff2fac191d9b862c9b6c9f519d55d68fcd57fff 100644
--- a/spec/javascripts/repo/components/repo_edit_button_spec.js
+++ b/spec/javascripts/repo/components/repo_edit_button_spec.js
@@ -9,6 +9,10 @@ describe('RepoEditButton', () => {
     return new RepoEditButton().$mount();
   }
 
+  afterEach(() => {
+    RepoStore.openedFiles = [];
+  });
+
   it('renders an edit button that toggles the view state', (done) => {
     RepoStore.isCommitable = true;
     RepoStore.changedFiles = [];
@@ -38,12 +42,4 @@ describe('RepoEditButton', () => {
 
     expect(vm.$el.innerHTML).toBeUndefined();
   });
-
-  describe('methods', () => {
-    describe('editCancelClicked', () => {
-      it('sets dialog to open when there are changedFiles');
-
-      it('toggles editMode and calls toggleBlobView');
-    });
-  });
 });
diff --git a/spec/javascripts/repo/components/repo_editor_spec.js b/spec/javascripts/repo/components/repo_editor_spec.js
index 85d55d171f91eaf733b783eac108742058f6c89a..a25a600b3be7564e20579b16443148bab95125d9 100644
--- a/spec/javascripts/repo/components/repo_editor_spec.js
+++ b/spec/javascripts/repo/components/repo_editor_spec.js
@@ -1,4 +1,5 @@
 import Vue from 'vue';
+import RepoStore from '~/repo/stores/repo_store';
 import repoEditor from '~/repo/components/repo_editor.vue';
 
 describe('RepoEditor', () => {
@@ -8,6 +9,10 @@ describe('RepoEditor', () => {
     this.vm = new RepoEditor().$mount();
   });
 
+  afterEach(() => {
+    RepoStore.openedFiles = [];
+  });
+
   it('renders an ide container', (done) => {
     this.vm.openedFiles = ['idiidid'];
     this.vm.binary = false;
diff --git a/spec/javascripts/repo/components/repo_file_buttons_spec.js b/spec/javascripts/repo/components/repo_file_buttons_spec.js
index dfab51710c3550f4f037b1f892ddb4ab11916f1e..701c260224f0ae23fcbdd23acc178a93dc142929 100644
--- a/spec/javascripts/repo/components/repo_file_buttons_spec.js
+++ b/spec/javascripts/repo/components/repo_file_buttons_spec.js
@@ -9,6 +9,10 @@ describe('RepoFileButtons', () => {
     return new RepoFileButtons().$mount();
   }
 
+  afterEach(() => {
+    RepoStore.openedFiles = [];
+  });
+
   it('renders Raw, Blame, History, Permalink and Preview toggle', () => {
     const activeFile = {
       extension: 'md',
diff --git a/spec/javascripts/repo/components/repo_file_options_spec.js b/spec/javascripts/repo/components/repo_file_options_spec.js
deleted file mode 100644
index 9759b4bf12d4f1410afde97040194c99f5b427e2..0000000000000000000000000000000000000000
--- a/spec/javascripts/repo/components/repo_file_options_spec.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import Vue from 'vue';
-import repoFileOptions from '~/repo/components/repo_file_options.vue';
-
-describe('RepoFileOptions', () => {
-  const projectName = 'projectName';
-
-  function createComponent(propsData) {
-    const RepoFileOptions = Vue.extend(repoFileOptions);
-
-    return new RepoFileOptions({
-      propsData,
-    }).$mount();
-  }
-
-  it('renders the title and new file/folder buttons if isMini is true', () => {
-    const vm = createComponent({
-      isMini: true,
-      projectName,
-    });
-
-    expect(vm.$el.classList.contains('repo-file-options')).toBeTruthy();
-    expect(vm.$el.querySelector('.title').textContent).toEqual(projectName);
-  });
-
-  it('does not render if isMini is false', () => {
-    const vm = createComponent({
-      isMini: false,
-      projectName,
-    });
-
-    expect(vm.$el.innerHTML).toBeFalsy();
-  });
-});
diff --git a/spec/javascripts/repo/components/repo_file_spec.js b/spec/javascripts/repo/components/repo_file_spec.js
index 620b604f4040635b35f179b5d000c0c5d325a5b5..334bf0997cae14f00ff24140affc624742fb77c1 100644
--- a/spec/javascripts/repo/components/repo_file_spec.js
+++ b/spec/javascripts/repo/components/repo_file_spec.js
@@ -1,21 +1,11 @@
 import Vue from 'vue';
 import repoFile from '~/repo/components/repo_file.vue';
 import RepoStore from '~/repo/stores/repo_store';
+import eventHub from '~/repo/event_hub';
+import { file } from '../mock_data';
 
 describe('RepoFile', () => {
   const updated = 'updated';
-  const file = {
-    icon: 'icon',
-    url: 'url',
-    name: 'name',
-    lastCommitMessage: 'message',
-    lastCommitUpdate: Date.now(),
-    level: 10,
-  };
-  const activeFile = {
-    pageTitle: 'pageTitle',
-    url: 'url',
-  };
   const otherFile = {
     html: '<p class="file-content">html</p>',
     pageTitle: 'otherpageTitle',
@@ -29,12 +19,15 @@ describe('RepoFile', () => {
     }).$mount();
   }
 
+  beforeEach(() => {
+    RepoStore.openedFiles = [];
+  });
+
   it('renders link, icon, name and last commit details', () => {
     const RepoFile = Vue.extend(repoFile);
     const vm = new RepoFile({
       propsData: {
-        file,
-        activeFile,
+        file: file(),
       },
     });
     spyOn(vm, 'timeFormated').and.returnValue(updated);
@@ -43,28 +36,20 @@ describe('RepoFile', () => {
     const name = vm.$el.querySelector('.repo-file-name');
     const fileIcon = vm.$el.querySelector('.file-icon');
 
-    expect(vm.$el.classList.contains('active')).toBeTruthy();
-    expect(vm.$el.querySelector(`.${file.icon}`).style.marginLeft).toEqual('100px');
-    expect(name.title).toEqual(file.url);
-    expect(name.href).toMatch(`/${file.url}`);
-    expect(name.textContent.trim()).toEqual(file.name);
-    expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(file.lastCommitMessage);
+    expect(vm.$el.querySelector(`.${vm.file.icon}`).style.marginLeft).toEqual('0px');
+    expect(name.href).toMatch(`/${vm.file.url}`);
+    expect(name.textContent.trim()).toEqual(vm.file.name);
+    expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(vm.file.lastCommit.message);
     expect(vm.$el.querySelector('.commit-update').textContent.trim()).toBe(updated);
-    expect(fileIcon.classList.contains(file.icon)).toBeTruthy();
-    expect(fileIcon.style.marginLeft).toEqual(`${file.level * 10}px`);
+    expect(fileIcon.classList.contains(vm.file.icon)).toBeTruthy();
+    expect(fileIcon.style.marginLeft).toEqual(`${vm.file.level * 10}px`);
   });
 
   it('does render if hasFiles is true and is loading tree', () => {
     const vm = createComponent({
-      file,
-      activeFile,
-      loading: {
-        tree: true,
-      },
-      hasFiles: true,
+      file: file(),
     });
 
-    expect(vm.$el.innerHTML).toBeTruthy();
     expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy();
   });
 
@@ -75,75 +60,51 @@ describe('RepoFile', () => {
   });
 
   it('renders a spinner if the file is loading', () => {
-    file.loading = true;
-    const vm = createComponent({
-      file,
-      activeFile,
-      loading: {
-        tree: true,
-      },
-      hasFiles: true,
-    });
-
-    expect(vm.$el.innerHTML).toBeTruthy();
-    expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${file.level * 10}px`);
-  });
-
-  it('does not render if loading tree', () => {
+    const f = file();
+    f.loading = true;
     const vm = createComponent({
-      file,
-      activeFile,
-      loading: {
-        tree: true,
-      },
+      file: f,
     });
 
-    expect(vm.$el.innerHTML).toBeFalsy();
+    expect(vm.$el.querySelector('.fa-spin.fa-spinner')).not.toBeNull();
+    expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`);
   });
 
   it('does not render commit message and datetime if mini', () => {
+    RepoStore.openedFiles.push(file());
+
     const vm = createComponent({
-      file,
-      activeFile,
-      isMini: true,
+      file: file(),
     });
 
     expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
     expect(vm.$el.querySelector('.commit-update')).toBeFalsy();
   });
 
-  it('does not set active class if file is active file', () => {
-    const vm = createComponent({
-      file,
-      activeFile: {},
-    });
-
-    expect(vm.$el.classList.contains('active')).toBeFalsy();
-  });
-
   it('fires linkClicked when the link is clicked', () => {
     const vm = createComponent({
-      file,
-      activeFile,
+      file: file(),
     });
 
     spyOn(vm, 'linkClicked');
 
-    vm.$el.querySelector('.repo-file-name').click();
+    vm.$el.click();
 
-    expect(vm.linkClicked).toHaveBeenCalledWith(file);
+    expect(vm.linkClicked).toHaveBeenCalledWith(vm.file);
   });
 
   describe('methods', () => {
     describe('linkClicked', () => {
-      const vm = jasmine.createSpyObj('vm', ['$emit']);
+      it('$emits fileNameClicked with file obj', () => {
+        spyOn(eventHub, '$emit');
 
-      it('$emits linkclicked with file obj', () => {
-        const theFile = {};
+        const vm = createComponent({
+          file: file(),
+        });
 
-        repoFile.methods.linkClicked.call(vm, theFile);
+        vm.linkClicked(vm.file);
 
-        expect(vm.$emit).toHaveBeenCalledWith('linkclicked', theFile);
+        expect(eventHub.$emit).toHaveBeenCalledWith('fileNameClicked', vm.file);
       });
     });
   });
diff --git a/spec/javascripts/repo/components/repo_loading_file_spec.js b/spec/javascripts/repo/components/repo_loading_file_spec.js
index a030314d7492df2e20e2fbb3810f7f8203d3f384..e9f95a02028fdec59b3ba9a4b67cd1ff14ac84f7 100644
--- a/spec/javascripts/repo/components/repo_loading_file_spec.js
+++ b/spec/javascripts/repo/components/repo_loading_file_spec.js
@@ -1,4 +1,5 @@
 import Vue from 'vue';
+import RepoStore from '~/repo/stores/repo_store';
 import repoLoadingFile from '~/repo/components/repo_loading_file.vue';
 
 describe('RepoLoadingFile', () => {
@@ -28,6 +29,10 @@ describe('RepoLoadingFile', () => {
     });
   }
 
+  afterEach(() => {
+    RepoStore.openedFiles = [];
+  });
+
   it('renders 3 columns of animated LoC', () => {
     const vm = createComponent({
       loading: {
@@ -42,38 +47,16 @@ describe('RepoLoadingFile', () => {
   });
 
   it('renders 1 column of animated LoC if isMini', () => {
+    RepoStore.openedFiles = new Array(1);
     const vm = createComponent({
       loading: {
         tree: true,
       },
       hasFiles: false,
-      isMini: true,
     });
     const columns = [...vm.$el.querySelectorAll('td')];
 
     expect(columns.length).toEqual(1);
     assertColumns(columns);
   });
-
-  it('does not render if tree is not loading', () => {
-    const vm = createComponent({
-      loading: {
-        tree: false,
-      },
-      hasFiles: false,
-    });
-
-    expect(vm.$el.innerHTML).toBeFalsy();
-  });
-
-  it('does not render if hasFiles is true', () => {
-    const vm = createComponent({
-      loading: {
-        tree: true,
-      },
-      hasFiles: true,
-    });
-
-    expect(vm.$el.innerHTML).toBeFalsy();
-  });
 });
diff --git a/spec/javascripts/repo/components/repo_prev_directory_spec.js b/spec/javascripts/repo/components/repo_prev_directory_spec.js
index 34dde545e6a3d8cf4bf5b76aade0fe05170afabd..4c064f210848b5c3a308425c15e284441852e33c 100644
--- a/spec/javascripts/repo/components/repo_prev_directory_spec.js
+++ b/spec/javascripts/repo/components/repo_prev_directory_spec.js
@@ -1,5 +1,6 @@
 import Vue from 'vue';
 import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue';
+import eventHub from '~/repo/event_hub';
 
 describe('RepoPrevDirectory', () => {
   function createComponent(propsData) {
@@ -20,7 +21,7 @@ describe('RepoPrevDirectory', () => {
     spyOn(vm, 'linkClicked');
 
     expect(link.href).toMatch(`/${prevUrl}`);
-    expect(link.textContent).toEqual('..');
+    expect(link.textContent).toEqual('...');
 
     link.click();
 
@@ -29,14 +30,17 @@ describe('RepoPrevDirectory', () => {
 
   describe('methods', () => {
     describe('linkClicked', () => {
-      const vm = jasmine.createSpyObj('vm', ['$emit']);
+      it('$emits linkclicked with prevUrl', () => {
+        const prevUrl = 'prevUrl';
+        const vm = createComponent({
+          prevUrl,
+        });
 
-      it('$emits linkclicked with file obj', () => {
-        const file = {};
+        spyOn(eventHub, '$emit');
 
-        repoPrevDirectory.methods.linkClicked.call(vm, file);
+        vm.linkClicked(prevUrl);
 
-        expect(vm.$emit).toHaveBeenCalledWith('linkclicked', file);
+        expect(eventHub.$emit).toHaveBeenCalledWith('goToPreviousDirectoryClicked', prevUrl);
       });
     });
   });
diff --git a/spec/javascripts/repo/components/repo_sidebar_spec.js b/spec/javascripts/repo/components/repo_sidebar_spec.js
index 35d2b37ac2a8199a7ab21b5a3c741b0ca8ec3dc3..61283da8257cc91e261336ce7e432255042548e4 100644
--- a/spec/javascripts/repo/components/repo_sidebar_spec.js
+++ b/spec/javascripts/repo/components/repo_sidebar_spec.js
@@ -3,6 +3,7 @@ import Helper from '~/repo/helpers/repo_helper';
 import RepoService from '~/repo/services/repo_service';
 import RepoStore from '~/repo/stores/repo_store';
 import repoSidebar from '~/repo/components/repo_sidebar.vue';
+import { file } from '../mock_data';
 
 describe('RepoSidebar', () => {
   let vm;
@@ -15,14 +16,15 @@ describe('RepoSidebar', () => {
 
   afterEach(() => {
     vm.$destroy();
+
+    RepoStore.files = [];
+    RepoStore.openedFiles = [];
   });
 
   it('renders a sidebar', () => {
-    RepoStore.files = [{
-      id: 0,
-    }];
+    RepoStore.files = [file()];
     RepoStore.openedFiles = [];
-    RepoStore.isRoot = false;
+    RepoStore.isRoot = true;
 
     vm = createComponent();
     const thead = vm.$el.querySelector('thead');
@@ -30,9 +32,9 @@ describe('RepoSidebar', () => {
 
     expect(vm.$el.id).toEqual('sidebar');
     expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy();
-    expect(thead.querySelector('.name').textContent).toEqual('Name');
-    expect(thead.querySelector('.last-commit').textContent).toEqual('Last commit');
-    expect(thead.querySelector('.last-update').textContent).toEqual('Last update');
+    expect(thead.querySelector('.name').textContent.trim()).toEqual('Name');
+    expect(thead.querySelector('.last-commit').textContent.trim()).toEqual('Last commit');
+    expect(thead.querySelector('.last-update').textContent.trim()).toEqual('Last update');
     expect(tbody.querySelector('.repo-file-options')).toBeFalsy();
     expect(tbody.querySelector('.prev-directory')).toBeFalsy();
     expect(tbody.querySelector('.loading-file')).toBeFalsy();
@@ -46,76 +48,74 @@ describe('RepoSidebar', () => {
     vm = createComponent();
 
     expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy();
-    expect(vm.$el.querySelector('thead')).toBeFalsy();
-    expect(vm.$el.querySelector('tbody .repo-file-options')).toBeTruthy();
+    expect(vm.$el.querySelector('thead')).toBeTruthy();
+    expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy();
   });
 
   it('renders 5 loading files if tree is loading and not hasFiles', () => {
-    RepoStore.loading = {
-      tree: true,
-    };
+    RepoStore.loading.tree = true;
     RepoStore.files = [];
     vm = createComponent();
 
     expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5);
   });
 
-  it('renders a prev directory if isRoot', () => {
-    RepoStore.files = [{
-      id: 0,
-    }];
-    RepoStore.isRoot = true;
+  it('renders a prev directory if is not root', () => {
+    RepoStore.files = [file()];
+    RepoStore.isRoot = false;
+    RepoStore.loading.tree = false;
     vm = createComponent();
 
     expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy();
   });
 
+  describe('flattendFiles', () => {
+    it('returns a flattend array of files', () => {
+      const f = file();
+      f.files.push(file('testing 123'));
+      const files = [f, file()];
+      vm = createComponent();
+      vm.files = files;
+
+      expect(vm.flattendFiles.length).toBe(3);
+      expect(vm.flattendFiles[1].name).toBe('testing 123');
+    });
+  });
+
   describe('methods', () => {
     describe('fileClicked', () => {
       it('should fetch data for new file', () => {
         spyOn(Helper, 'getContent').and.callThrough();
-        const file1 = {
-          id: 0,
-          url: '',
-        };
-        RepoStore.files = [file1];
+        RepoStore.files = [file()];
         RepoStore.isRoot = true;
         vm = createComponent();
 
-        vm.fileClicked(file1);
+        vm.fileClicked(RepoStore.files[0]);
 
-        expect(Helper.getContent).toHaveBeenCalledWith(file1);
+        expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[0]);
       });
 
       it('should not fetch data for already opened files', () => {
-        const file = {
-          id: 42,
-          url: 'foo',
-        };
-
-        spyOn(Helper, 'getFileFromPath').and.returnValue(file);
+        const f = file();
+        spyOn(Helper, 'getFileFromPath').and.returnValue(f);
         spyOn(RepoStore, 'setActiveFiles');
         vm = createComponent();
-        vm.fileClicked(file);
+        vm.fileClicked(f);
 
-        expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(file);
+        expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(f);
       });
 
       it('should hide files in directory if already open', () => {
-        spyOn(RepoStore, 'removeChildFilesOfTree').and.callThrough();
-        const file1 = {
-          id: 0,
-          type: 'tree',
-          url: '',
-          opened: true,
-        };
-        RepoStore.files = [file1];
-        RepoStore.isRoot = true;
+        spyOn(Helper, 'setDirectoryToClosed').and.callThrough();
+        const f = file();
+        f.opened = true;
+        f.type = 'tree';
+        RepoStore.files = [f];
         vm = createComponent();
 
-        vm.fileClicked(file1);
+        vm.fileClicked(RepoStore.files[0]);
 
-        expect(RepoStore.removeChildFilesOfTree).toHaveBeenCalledWith(file1);
+        expect(Helper.setDirectoryToClosed).toHaveBeenCalledWith(RepoStore.files[0]);
       });
     });
 
@@ -131,36 +131,31 @@ describe('RepoSidebar', () => {
     });
 
     describe('back button', () => {
-      const file1 = {
-        id: 1,
-        url: 'file1',
-      };
-      const file2 = {
-        id: 2,
-        url: 'file2',
-      };
-      RepoStore.files = [file1, file2];
-      RepoStore.openedFiles = [file1, file2];
-      RepoStore.isRoot = true;
+      beforeEach(() => {
+        const f = file();
+        const file2 = Object.assign({}, file());
+        file2.url = 'test';
+        RepoStore.files = [f, file2];
+        RepoStore.openedFiles = [];
+        RepoStore.isRoot = true;
 
-      vm = createComponent();
-      vm.fileClicked(file1);
+        vm = createComponent();
+      });
 
       it('render previous file when using back button', () => {
         spyOn(Helper, 'getContent').and.callThrough();
 
-        vm.fileClicked(file2);
-        expect(Helper.getContent).toHaveBeenCalledWith(file2);
-        Helper.getContent.calls.reset();
+        vm.fileClicked(RepoStore.files[1]);
+        expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[1]);
 
         history.pushState({
           key: Math.random(),
-        }, '', file1.url);
+        }, '', RepoStore.files[1].url);
         const popEvent = document.createEvent('Event');
         popEvent.initEvent('popstate', true, true);
         window.dispatchEvent(popEvent);
 
-        expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(file1.url);
+        expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(RepoStore.files[1].url);
 
         window.history.pushState({}, null, '/');
       });
diff --git a/spec/javascripts/repo/components/repo_tab_spec.js b/spec/javascripts/repo/components/repo_tab_spec.js
index d2a790ad73a9f94e71bd8b777842c6a856a2c42a..37e297437f09340dd78b6133609f115cbccfe45f 100644
--- a/spec/javascripts/repo/components/repo_tab_spec.js
+++ b/spec/javascripts/repo/components/repo_tab_spec.js
@@ -1,5 +1,6 @@
 import Vue from 'vue';
 import repoTab from '~/repo/components/repo_tab.vue';
+import RepoStore from '~/repo/stores/repo_store';
 
 describe('RepoTab', () => {
   function createComponent(propsData) {
@@ -18,7 +19,7 @@ describe('RepoTab', () => {
     const vm = createComponent({
       tab,
     });
-    const close = vm.$el.querySelector('.close');
+    const close = vm.$el.querySelector('.close-btn');
     const name = vm.$el.querySelector(`a[title="${tab.url}"]`);
 
     spyOn(vm, 'closeTab');
@@ -44,26 +45,43 @@ describe('RepoTab', () => {
       tab,
     });
 
-    expect(vm.$el.querySelector('.close .fa-circle')).toBeTruthy();
+    expect(vm.$el.querySelector('.close-btn .fa-circle')).toBeTruthy();
   });
 
   describe('methods', () => {
     describe('closeTab', () => {
-      const vm = jasmine.createSpyObj('vm', ['$emit']);
-
       it('returns undefined and does not $emit if file is changed', () => {
-        const file = { changed: true };
-        const returnVal = repoTab.methods.closeTab.call(vm, file);
+        const tab = {
+          url: 'url',
+          name: 'name',
+          changed: true,
+        };
+        const vm = createComponent({
+          tab,
+        });
+
+        spyOn(RepoStore, 'removeFromOpenedFiles');
+
+        vm.$el.querySelector('.close-btn').click();
 
-        expect(returnVal).toBeUndefined();
-        expect(vm.$emit).not.toHaveBeenCalled();
+        expect(RepoStore.removeFromOpenedFiles).not.toHaveBeenCalled();
       });
 
       it('$emits tabclosed event with file obj', () => {
-        const file = { changed: false };
-        repoTab.methods.closeTab.call(vm, file);
+        const tab = {
+          url: 'url',
+          name: 'name',
+          changed: false,
+        };
+        const vm = createComponent({
+          tab,
+        });
+
+        spyOn(RepoStore, 'removeFromOpenedFiles');
+
+        vm.$el.querySelector('.close-btn').click();
 
-        expect(vm.$emit).toHaveBeenCalledWith('tabclosed', file);
+        expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(tab);
       });
     });
   });
diff --git a/spec/javascripts/repo/components/repo_tabs_spec.js b/spec/javascripts/repo/components/repo_tabs_spec.js
index a02b54efafc743e39dde934ed711887bbd923e54..431129bc866baaf85ec5e93a4dfbcd03b70140e8 100644
--- a/spec/javascripts/repo/components/repo_tabs_spec.js
+++ b/spec/javascripts/repo/components/repo_tabs_spec.js
@@ -16,6 +16,10 @@ describe('RepoTabs', () => {
     return new RepoTabs().$mount();
   }
 
+  afterEach(() => {
+    RepoStore.openedFiles = [];
+  });
+
   it('renders a list of tabs', () => {
     RepoStore.openedFiles = openedFiles;
 
@@ -28,18 +32,4 @@ describe('RepoTabs', () => {
     expect(tabs[1].classList.contains('active')).toBeFalsy();
     expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy();
   });
-
-  describe('methods', () => {
-    describe('tabClosed', () => {
-      it('calls removeFromOpenedFiles with file obj', () => {
-        const file = {};
-
-        spyOn(RepoStore, 'removeFromOpenedFiles');
-
-        repoTabs.methods.tabClosed(file);
-
-        expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(file);
-      });
-    });
-  });
 });
diff --git a/spec/javascripts/repo/mock_data.js b/spec/javascripts/repo/mock_data.js
new file mode 100644
index 0000000000000000000000000000000000000000..836b867205e7894fab2246c0560da9a0a92ead01
--- /dev/null
+++ b/spec/javascripts/repo/mock_data.js
@@ -0,0 +1,13 @@
+import RepoHelper from '~/repo/helpers/repo_helper';
+
+// eslint-disable-next-line import/prefer-default-export
+export const file = (name = 'name') => RepoHelper.serializeRepoEntity('blob', {
+  icon: 'icon',
+  url: 'url',
+  name,
+  last_commit: {
+    id: '123',
+    message: 'test',
+    committed_date: '',
+  },
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
similarity index 100%
rename from spec/javascripts/vue_shared/components/user_avatar_image_spec.js
rename to spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
diff --git a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
similarity index 100%
rename from spec/javascripts/vue_shared/components/user_avatar_link_spec.js
rename to spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
diff --git a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js
similarity index 100%
rename from spec/javascripts/vue_shared/components/user_avatar_svg_spec.js
rename to spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js
diff --git a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
index d2e7243ee05a40719a8d8737349825ad7f44ad73..4d3fdbd9554825a835c8fefa58ed61087909f040 100644
--- a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
+++ b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
@@ -31,8 +31,8 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
       end
 
       it 'creates correct entries in the merge_request_diff_commits table' do
-        expect(updated_merge_request_diff.merge_request_diff_commits.count).to eq(commits.count)
-        expect(updated_merge_request_diff.commits.map(&:to_hash)).to eq(commits)
+        expect(updated_merge_request_diff.merge_request_diff_commits.count).to eq(expected_commits.count)
+        expect(updated_merge_request_diff.commits.map(&:to_hash)).to eq(expected_commits)
       end
 
       it 'creates correct entries in the merge_request_diff_files table' do
@@ -199,6 +199,16 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
 
     context 'when the merge request diff has valid commits and diffs' do
       let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+      let(:expected_commits) { commits }
+      let(:diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
+      let(:expected_diffs) { diffs }
+
+      include_examples 'updated MR diff'
+    end
+
+    context 'when the merge request diff has diffs but no commits' do
+      let(:commits) { nil }
+      let(:expected_commits) { [] }
       let(:diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
       let(:expected_diffs) { diffs }
 
@@ -207,6 +217,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
 
     context 'when the merge request diffs do not have too_large set' do
       let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+      let(:expected_commits) { commits }
       let(:expected_diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
 
       let(:diffs) do
@@ -218,6 +229,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
 
     context 'when the merge request diffs do not have a_mode and b_mode set' do
       let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+      let(:expected_commits) { commits }
       let(:expected_diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
 
       let(:diffs) do
@@ -229,6 +241,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
 
     context 'when the merge request diffs have binary content' do
       let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+      let(:expected_commits) { commits }
       let(:expected_diffs) { diffs }
 
       # The start of a PDF created by Illustrator
@@ -257,6 +270,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
 
     context 'when the merge request diff has commits, but no diffs' do
       let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+      let(:expected_commits) { commits }
       let(:diffs) { [] }
       let(:expected_diffs) { diffs }
 
@@ -265,6 +279,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
 
     context 'when the merge request diffs have invalid content' do
       let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+      let(:expected_commits) { commits }
       let(:diffs) { ['--broken-diff'] }
       let(:expected_diffs) { [] }
 
@@ -274,6 +289,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
     context 'when the merge request diffs are Rugged::Patch instances' do
       let(:commits) { merge_request_diff.commits.map(&:to_hash) }
       let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) }
+      let(:expected_commits) { commits }
       let(:diffs) { first_commit.rugged_diff_from_parent.patches }
       let(:expected_diffs) { [] }
 
@@ -283,6 +299,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
     context 'when the merge request diffs are Rugged::Diff::Delta instances' do
       let(:commits) { merge_request_diff.commits.map(&:to_hash) }
       let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) }
+      let(:expected_commits) { commits }
       let(:diffs) { first_commit.rugged_diff_from_parent.deltas }
       let(:expected_diffs) { [] }
 
diff --git a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
index 98cf7966dad67c88e655319b2b283ba5053db858..c8d532df0590540f2712f7dec6e5106d15a041de 100644
--- a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
+++ b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
@@ -10,18 +10,10 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
     # Override test-settings for the circuitbreaker with something more realistic
     # for these specs.
     stub_storage_settings('default' => {
-                            'path' => TestEnv.repos_path,
-                            'failure_count_threshold' => 10,
-                            'failure_wait_time' => 30,
-                            'failure_reset_time' => 1800,
-                            'storage_timeout' => 5
+                            'path' => TestEnv.repos_path
                           },
                           'broken' => {
-                            'path' => 'tmp/tests/non-existent-repositories',
-                            'failure_count_threshold' => 10,
-                            'failure_wait_time' => 30,
-                            'failure_reset_time' => 1800,
-                            'storage_timeout' => 5
+                            'path' => 'tmp/tests/non-existent-repositories'
                           },
                           'nopath' => { 'path' => nil }
                          )
@@ -49,6 +41,10 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
 
       expect(key_exists).to be_falsey
     end
+
+    it 'does not break when there are no keys in redis' do
+      expect { described_class.reset_all! }.not_to raise_error
+    end
   end
 
   describe '.for_storage' do
@@ -75,10 +71,39 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
       expect(circuit_breaker.hostname).to eq(hostname)
       expect(circuit_breaker.storage).to eq('default')
       expect(circuit_breaker.storage_path).to eq(TestEnv.repos_path)
-      expect(circuit_breaker.failure_count_threshold).to eq(10)
-      expect(circuit_breaker.failure_wait_time).to eq(30)
-      expect(circuit_breaker.failure_reset_time).to eq(1800)
-      expect(circuit_breaker.storage_timeout).to eq(5)
+    end
+  end
+
+  context 'circuitbreaker settings' do
+    before do
+      stub_application_setting(circuitbreaker_failure_count_threshold: 0,
+                               circuitbreaker_failure_wait_time: 1,
+                               circuitbreaker_failure_reset_time: 2,
+                               circuitbreaker_storage_timeout: 3)
+    end
+
+    describe '#failure_count_threshold' do
+      it 'reads the value from settings' do
+        expect(circuit_breaker.failure_count_threshold).to eq(0)
+      end
+    end
+
+    describe '#failure_wait_time' do
+      it 'reads the value from settings' do
+        expect(circuit_breaker.failure_wait_time).to eq(1)
+      end
+    end
+
+    describe '#failure_reset_time' do
+      it 'reads the value from settings' do
+        expect(circuit_breaker.failure_reset_time).to eq(2)
+      end
+    end
+
+    describe '#storage_timeout' do
+      it 'reads the value from settings' do
+        expect(circuit_breaker.storage_timeout).to eq(3)
+      end
     end
   end
 
@@ -151,10 +176,7 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
 
     context 'the `failure_wait_time` is set to 0' do
       before do
-        stub_storage_settings('default' => {
-                                'failure_wait_time' => 0,
-                                'path' => TestEnv.repos_path
-                              })
+        stub_application_setting(circuitbreaker_failure_wait_time: 0)
       end
 
       it 'is working even when there is a recent failure' do
diff --git a/spec/lib/gitlab/git/storage/health_spec.rb b/spec/lib/gitlab/git/storage/health_spec.rb
index 2d3af3879718f6603d4c75ec145a29d6e14b99ea..4a14a5201d103b64555e4778a4472e101a1b7cb2 100644
--- a/spec/lib/gitlab/git/storage/health_spec.rb
+++ b/spec/lib/gitlab/git/storage/health_spec.rb
@@ -20,36 +20,6 @@ describe Gitlab::Git::Storage::Health, clean_gitlab_redis_shared_state: true, br
     end
   end
 
-  describe '.load_for_keys' do
-    let(:subject) do
-      results = Gitlab::Git::Storage.redis.with do |redis|
-        fake_future = double
-        allow(fake_future).to receive(:value).and_return([host1_key])
-        described_class.load_for_keys({ 'broken' => fake_future }, redis)
-      end
-
-      # Make sure the `Redis#future is loaded
-      results.inject({}) do |result, (name, info)|
-        info.each { |i| i[:failure_count] = i[:failure_count].value.to_i }
-
-        result[name] = info
-
-        result
-      end
-    end
-
-    it 'loads when there is no info in redis' do
-      expect(subject).to eq('broken' => [{ name: host1_key, failure_count: 0 }])
-    end
-
-    it 'reads the correct values for a storage from redis' do
-      set_in_redis(host1_key, 5)
-      set_in_redis(host2_key, 7)
-
-      expect(subject).to eq('broken' => [{ name: host1_key, failure_count: 5 }])
-    end
-  end
-
   describe '.for_all_storages' do
     it 'loads health status for all configured storages' do
       healths = described_class.for_all_storages
diff --git a/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb
index 0e645008c8873b4f61d40d1003f5e68f6e48f32b..7ee6d2f370945b51d5e9b1b9ac31bb6c0814ab7d 100644
--- a/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb
+++ b/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb
@@ -54,6 +54,10 @@ describe Gitlab::Git::Storage::NullCircuitBreaker do
   end
 
   describe '#failure_count_threshold' do
+    before do
+      stub_application_setting(circuitbreaker_failure_count_threshold: 1)
+    end
+
     it { expect(breaker.failure_count_threshold).to eq(1) }
   end
 
diff --git a/spec/lib/gitlab/group_hierarchy_spec.rb b/spec/lib/gitlab/group_hierarchy_spec.rb
index 8dc83a6db7f1a7018c0a7fab8b0fcc476da55bf7..30686634af4cb4a1d541f78199d4e24b728203ea 100644
--- a/spec/lib/gitlab/group_hierarchy_spec.rb
+++ b/spec/lib/gitlab/group_hierarchy_spec.rb
@@ -18,6 +18,12 @@ describe Gitlab::GroupHierarchy, :postgresql do
       expect(relation).to include(parent, child1)
     end
 
+    it 'can find ancestors upto a certain level' do
+      relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1)
+
+      expect(relation).to contain_exactly(child2)
+    end
+
     it 'uses ancestors_base #initialize argument' do
       relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors
 
@@ -55,6 +61,28 @@ describe Gitlab::GroupHierarchy, :postgresql do
     end
   end
 
+  describe '#descendants' do
+    it 'includes only the descendants' do
+      relation = described_class.new(Group.where(id: parent)).descendants
+
+      expect(relation).to contain_exactly(child1, child2)
+    end
+  end
+
+  describe '#ancestors' do
+    it 'includes only the ancestors' do
+      relation = described_class.new(Group.where(id: child2)).ancestors
+
+      expect(relation).to contain_exactly(child1, parent)
+    end
+
+    it 'can find ancestors upto a certain level' do
+      relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1)
+
+      expect(relation).to be_empty
+    end
+  end
+
   describe '#all_groups' do
     let(:relation) do
       described_class.new(Group.where(id: child1.id)).all_groups
diff --git a/spec/lib/gitlab/multi_collection_paginator_spec.rb b/spec/lib/gitlab/multi_collection_paginator_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..68bd4f9315942d6eecf40b8506dc04e155779a55
--- /dev/null
+++ b/spec/lib/gitlab/multi_collection_paginator_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe Gitlab::MultiCollectionPaginator do
+  subject(:paginator) { described_class.new(Project.all.order(:id), Group.all.order(:id), per_page: 3) }
+
+  it 'combines both collections' do
+    project = create(:project)
+    group = create(:group)
+
+    expect(paginator.paginate(1)).to eq([project, group])
+  end
+
+  it 'includes elements second collection if first collection is empty' do
+    group = create(:group)
+
+    expect(paginator.paginate(1)).to eq([group])
+  end
+
+  context 'with a full first page' do
+    let!(:all_groups) { create_list(:group, 4) }
+    let!(:all_projects) { create_list(:project, 4) }
+
+    it 'knows the total count of the collection' do
+      expect(paginator.total_count).to eq(8)
+    end
+
+    it 'fills the first page with elements of the first collection' do
+      expect(paginator.paginate(1)).to eq(all_projects.take(3))
+    end
+
+    it 'fils the second page with a mixture of of the first & second collection' do
+      first_collection_element = all_projects.last
+      second_collection_elements = all_groups.take(2)
+
+      expected_collection = [first_collection_element] + second_collection_elements
+
+      expect(paginator.paginate(2)).to eq(expected_collection)
+    end
+
+    it 'fils the last page with elements from the second collection' do
+      expected_collection = all_groups[-2..-1]
+
+      expect(paginator.paginate(3)).to eq(expected_collection)
+    end
+  end
+end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index 1f1c48ee9b5bf17d3f0d937c140490cdebd63e9e..f1f188cbfb53842420809884d2a25bbaee1fa2db 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -213,7 +213,7 @@ describe Gitlab::PathRegex do
     it 'accepts group routes' do
       expect(subject).to match('activity/')
       expect(subject).to match('group_members/')
-      expect(subject).to match('subgroups/')
+      expect(subject).to match('labels/')
     end
 
     it 'is not case sensitive' do
@@ -246,7 +246,7 @@ describe Gitlab::PathRegex do
         it 'accepts group routes' do
           expect(subject).to match('activity/')
           expect(subject).to match('group_members/')
-          expect(subject).to match('subgroups/')
+          expect(subject).to match('labels/')
         end
       end
 
@@ -268,7 +268,7 @@ describe Gitlab::PathRegex do
         it 'accepts group routes' do
           expect(subject).to match('activity/more/')
           expect(subject).to match('group_members/more/')
-          expect(subject).to match('subgroups/more/')
+          expect(subject).to match('labels/more/')
         end
       end
     end
@@ -292,7 +292,7 @@ describe Gitlab::PathRegex do
         it 'rejects group routes' do
           expect(subject).not_to match('root/activity/')
           expect(subject).not_to match('root/group_members/')
-          expect(subject).not_to match('root/subgroups/')
+          expect(subject).not_to match('root/labels/')
         end
       end
 
@@ -314,7 +314,7 @@ describe Gitlab::PathRegex do
         it 'rejects group routes' do
           expect(subject).not_to match('root/activity/more/')
           expect(subject).not_to match('root/group_members/more/')
-          expect(subject).not_to match('root/subgroups/more/')
+          expect(subject).not_to match('root/labels/more/')
         end
       end
     end
@@ -349,7 +349,7 @@ describe Gitlab::PathRegex do
     it 'accepts group routes' do
       expect(subject).to match('activity/')
       expect(subject).to match('group_members/')
-      expect(subject).to match('subgroups/')
+      expect(subject).to match('labels/')
     end
 
     it 'is not case sensitive' do
@@ -382,7 +382,7 @@ describe Gitlab::PathRegex do
     it 'accepts group routes' do
       expect(subject).to match('root/activity/')
       expect(subject).to match('root/group_members/')
-      expect(subject).to match('root/subgroups/')
+      expect(subject).to match('root/labels/')
     end
 
     it 'is not case sensitive' do
diff --git a/spec/lib/gitlab/sql/union_spec.rb b/spec/lib/gitlab/sql/union_spec.rb
index 8026fba9f0a2cb09d4c0e324fec74cec33a0406c..fe6422c32b6e670392b2e813a39e90a6d7a09eb1 100644
--- a/spec/lib/gitlab/sql/union_spec.rb
+++ b/spec/lib/gitlab/sql/union_spec.rb
@@ -29,5 +29,12 @@ describe Gitlab::SQL::Union do
 
       expect(union.to_sql).to include('UNION ALL')
     end
+
+    it 'returns `NULL` if all relations are empty' do
+      empty_relation = User.none
+      union = described_class.new([empty_relation, empty_relation])
+
+      expect(union.to_sql).to eq('NULL')
+    end
   end
 end
diff --git a/spec/lib/gitlab/utils/merge_hash_spec.rb b/spec/lib/gitlab/utils/merge_hash_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4fa7bb31301c41a6056a3a705e347d1505fedaae
--- /dev/null
+++ b/spec/lib/gitlab/utils/merge_hash_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+describe Gitlab::Utils::MergeHash do
+  describe '.crush' do
+    it 'can flatten a hash to each element' do
+      input = { hello: "world", this: { crushes: ["an entire", "hash"] } }
+      expected_result = [:hello, "world", :this, :crushes, "an entire", "hash"]
+
+      expect(described_class.crush(input)).to eq(expected_result)
+    end
+  end
+
+  describe '.elements' do
+    it 'deep merges an array of elements' do
+      input = [{ hello: ["world"] },
+               { hello: "Everyone" },
+               { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] } },
+               "Goodbye", "Hallo"]
+      expected_output = [
+        {
+          hello:
+            [
+              "world",
+              "Everyone",
+              { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] }
+            ]
+        },
+        "Goodbye"
+      ]
+
+      expect(described_class.merge(input)).to eq(expected_output)
+    end
+  end
+end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 78cacf9ff5d6292d63070133c5b386a95210bc5d..6945c90cb9b7c7fe02b273fa78592480751e4eed 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -114,6 +114,19 @@ describe ApplicationSetting do
       it { expect(setting.repository_storages).to eq(['default']) }
     end
 
+    context 'circuitbreaker settings' do
+      [:circuitbreaker_failure_count_threshold,
+       :circuitbreaker_failure_wait_time,
+       :circuitbreaker_failure_reset_time,
+       :circuitbreaker_storage_timeout].each do |field|
+        it "Validates #{field} as number" do
+          is_expected.to validate_numericality_of(field)
+                           .only_integer
+                           .is_greater_than_or_equal_to(0)
+        end
+      end
+    end
+
     context 'repository storages' do
       before do
         storages = {
@@ -209,6 +222,16 @@ describe ApplicationSetting do
     end
   end
 
+  context 'restrict creating duplicates' do
+    before do
+      described_class.create_from_defaults
+    end
+
+    it 'raises an record creation violation if already created' do
+      expect { described_class.create_from_defaults }.to raise_error(ActiveRecord::RecordNotUnique)
+    end
+  end
+
   context 'restricted signup domains' do
     it 'sets single domain' do
       setting.domain_whitelist_raw = 'example.com'
diff --git a/spec/models/concerns/group_descendant_spec.rb b/spec/models/concerns/group_descendant_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c163fb01a8183c84e98b29ba98a7c1a1ac09fae7
--- /dev/null
+++ b/spec/models/concerns/group_descendant_spec.rb
@@ -0,0 +1,166 @@
+require 'spec_helper'
+
+describe GroupDescendant, :nested_groups do
+  let(:parent) { create(:group) }
+  let(:subgroup) { create(:group, parent: parent) }
+  let(:subsub_group) { create(:group, parent: subgroup) }
+
+  def all_preloaded_groups(*groups)
+    groups + [parent, subgroup, subsub_group]
+  end
+
+  context 'for a group' do
+    describe '#hierarchy' do
+      it 'only queries once for the ancestors' do
+        # make sure the subsub_group does not have anything cached
+        test_group = create(:group, parent: subsub_group).reload
+
+        query_count = ActiveRecord::QueryRecorder.new { test_group.hierarchy }.count
+
+        expect(query_count).to eq(1)
+      end
+
+      it 'only queries once for the ancestors when a top is given' do
+        test_group = create(:group, parent: subsub_group).reload
+
+        recorder = ActiveRecord::QueryRecorder.new { test_group.hierarchy(subgroup) }
+        expect(recorder.count).to eq(1)
+      end
+
+      it 'builds a hierarchy for a group' do
+        expected_hierarchy = { parent => { subgroup => subsub_group } }
+
+        expect(subsub_group.hierarchy).to eq(expected_hierarchy)
+      end
+
+      it 'builds a hierarchy upto a specified parent' do
+        expected_hierarchy = { subgroup => subsub_group }
+
+        expect(subsub_group.hierarchy(parent)).to eq(expected_hierarchy)
+      end
+
+      it 'raises an error if specifying a base that is not part of the tree' do
+        expect { subsub_group.hierarchy(build_stubbed(:group)) }
+          .to raise_error('specified top is not part of the tree')
+      end
+    end
+
+    describe '.build_hierarchy' do
+      it 'combines hierarchies until the top' do
+        other_subgroup = create(:group, parent: parent)
+        other_subsub_group = create(:group, parent: subgroup)
+
+        groups = all_preloaded_groups(other_subgroup, subsub_group, other_subsub_group)
+
+        expected_hierarchy = { parent => [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }] }
+
+        expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
+      end
+
+      it 'combines upto a given parent' do
+        other_subgroup = create(:group, parent: parent)
+        other_subsub_group = create(:group, parent: subgroup)
+
+        groups = [other_subgroup, subsub_group, other_subsub_group]
+        groups << subgroup # Add the parent as if it was preloaded
+
+        expected_hierarchy = [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }]
+        expect(described_class.build_hierarchy(groups, parent)).to eq(expected_hierarchy)
+      end
+
+      it 'handles building a tree out of order' do
+        other_subgroup = create(:group, parent: parent)
+        other_subgroup2 = create(:group, parent: parent)
+        other_subsub_group = create(:group, parent: other_subgroup)
+
+        groups = all_preloaded_groups(subsub_group, other_subgroup2, other_subsub_group, other_subgroup)
+        expected_hierarchy = { parent => [{ subgroup => subsub_group }, other_subgroup2, { other_subgroup => other_subsub_group }] }
+
+        expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
+      end
+
+      it 'raises an error if not all elements were preloaded' do
+        expect { described_class.build_hierarchy([subsub_group]) }
+          .to raise_error('parent was not preloaded')
+      end
+    end
+  end
+
+  context 'for a project' do
+    let(:project) { create(:project, namespace: subsub_group) }
+
+    describe '#hierarchy' do
+      it 'builds a hierarchy for a project' do
+        expected_hierarchy = { parent => { subgroup => { subsub_group => project } } }
+
+        expect(project.hierarchy).to eq(expected_hierarchy)
+      end
+
+      it 'builds a hierarchy upto a specified parent' do
+        expected_hierarchy = { subsub_group => project }
+
+        expect(project.hierarchy(subgroup)).to eq(expected_hierarchy)
+      end
+    end
+
+    describe '.build_hierarchy' do
+      it 'combines hierarchies until the top' do
+        other_project = create(:project, namespace: parent)
+        other_subgroup_project = create(:project, namespace: subgroup)
+
+        elements = all_preloaded_groups(other_project, subsub_group, other_subgroup_project)
+
+        expected_hierarchy = { parent => [other_project, { subgroup => [subsub_group, other_subgroup_project] }] }
+
+        expect(described_class.build_hierarchy(elements)).to eq(expected_hierarchy)
+      end
+
+      it 'combines upto a given parent' do
+        other_project = create(:project, namespace: parent)
+        other_subgroup_project = create(:project, namespace: subgroup)
+
+        elements = [other_project, subsub_group, other_subgroup_project]
+        elements << subgroup # Added as if it was preloaded
+
+        expected_hierarchy = [other_project, { subgroup => [subsub_group, other_subgroup_project] }]
+
+        expect(described_class.build_hierarchy(elements, parent)).to eq(expected_hierarchy)
+      end
+
+      it 'merges to elements in the same hierarchy' do
+        expected_hierarchy = { parent => subgroup }
+
+        expect(described_class.build_hierarchy([parent, subgroup])).to eq(expected_hierarchy)
+      end
+
+      it 'merges complex hierarchies' do
+        project = create(:project, namespace: parent)
+        sub_project = create(:project, namespace: subgroup)
+        subsubsub_group = create(:group, parent: subsub_group)
+        subsub_project = create(:project, namespace: subsub_group)
+        subsubsub_project = create(:project, namespace: subsubsub_group)
+        other_subgroup = create(:group, parent: parent)
+        other_subproject = create(:project, namespace: other_subgroup)
+
+        elements = [project, subsubsub_project, sub_project, other_subproject, subsub_project]
+        # Add parent groups as if they were preloaded
+        elements += [other_subgroup, subsubsub_group, subsub_group, subgroup]
+
+        expected_hierarchy = [
+          project,
+          {
+            subgroup => [
+              { subsub_group => [{ subsubsub_group => subsubsub_project }, subsub_project] },
+              sub_project
+            ]
+          },
+          { other_subgroup => other_subproject }
+        ]
+
+        actual_hierarchy = described_class.build_hierarchy(elements, parent)
+
+        expect(actual_hierarchy).to eq(expected_hierarchy)
+      end
+    end
+  end
+end
diff --git a/spec/models/concerns/loaded_in_group_list_spec.rb b/spec/models/concerns/loaded_in_group_list_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7a279547a3a5e88399ea840c9710f59de479797a
--- /dev/null
+++ b/spec/models/concerns/loaded_in_group_list_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe LoadedInGroupList do
+  let(:parent) { create(:group) }
+  subject(:found_group) { Group.with_selects_for_list.find_by(id: parent.id) }
+
+  describe '.with_selects_for_list' do
+    it 'includes the preloaded counts for groups' do
+      create(:group, parent: parent)
+      create(:project, namespace: parent)
+      parent.add_developer(create(:user))
+
+      found_group = Group.with_selects_for_list.find_by(id: parent.id)
+
+      expect(found_group.preloaded_project_count).to eq(1)
+      expect(found_group.preloaded_subgroup_count).to eq(1)
+      expect(found_group.preloaded_member_count).to eq(1)
+    end
+
+    context 'with archived projects' do
+      it 'counts including archived projects when `true` is passed' do
+        create(:project, namespace: parent, archived: true)
+        create(:project, namespace: parent)
+
+        found_group = Group.with_selects_for_list(archived: 'true').find_by(id: parent.id)
+
+        expect(found_group.preloaded_project_count).to eq(2)
+      end
+
+      it 'counts only archived projects when `only` is passed' do
+        create_list(:project, 2, namespace: parent, archived: true)
+        create(:project, namespace: parent)
+
+        found_group = Group.with_selects_for_list(archived: 'only').find_by(id: parent.id)
+
+        expect(found_group.preloaded_project_count).to eq(2)
+      end
+    end
+  end
+
+  describe '#children_count' do
+    it 'counts groups and projects' do
+      create(:group, parent: parent)
+      create(:project, namespace: parent)
+
+      expect(found_group.children_count).to eq(2)
+    end
+  end
+end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index a241d4111bdf1b3dc8eb0cbe1d6d6b0a90c4611e..90b768f595e8b6f2e3aecc6daf3eddff9c0cdd8c 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -154,6 +154,20 @@ describe Namespace do
     end
   end
 
+  describe '#ancestors_upto', :nested_groups do
+    let(:parent) { create(:group) }
+    let(:child) { create(:group, parent: parent) }
+    let(:child2) { create(:group, parent: child) }
+
+    it 'returns all ancestors when no namespace is given' do
+      expect(child2.ancestors_upto).to contain_exactly(child, parent)
+    end
+
+    it 'includes ancestors upto but excluding the given ancestor' do
+      expect(child2.ancestors_upto(parent)).to contain_exactly(child)
+    end
+  end
+
   describe '#move_dir', :request_store do
     let(:namespace) { create(:namespace) }
     let!(:project) { create(:project_empty_repo, namespace: namespace) }
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index cf26dbfea497d35619679001e0cbbcda1cd7a5d5..74eba7e33f664d3c4f45f1d0d83dd08622ea8fb7 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1761,6 +1761,21 @@ describe Project do
     it { expect(project.gitea_import?).to be true }
   end
 
+  describe '#ancestors_upto', :nested_groups do
+    let(:parent) { create(:group) }
+    let(:child) { create(:group, parent: parent) }
+    let(:child2) { create(:group, parent: child) }
+    let(:project) { create(:project, namespace: child2) }
+
+    it 'returns all ancestors when no namespace is given' do
+      expect(project.ancestors_upto).to contain_exactly(child2, child, parent)
+    end
+
+    it 'includes ancestors upto but excluding the given ancestor' do
+      expect(project.ancestors_upto(parent)).to contain_exactly(child2, child)
+    end
+  end
+
   describe '#lfs_enabled?' do
     let(:project) { create(:project) }
 
@@ -2178,6 +2193,12 @@ describe Project do
     it { expect(project.parent).to eq(project.namespace) }
   end
 
+  describe '#parent_id' do
+    let(:project) { create(:project) }
+
+    it { expect(project.parent_id).to eq(project.namespace_id) }
+  end
+
   describe '#parent_changed?' do
     let(:project) { create(:project) }
 
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 0b9a4b5c3db81acafd429d3302b82c72c9364f44..c24de58ee9d406cb914c4387527f5868bd08dcaf 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -23,6 +23,7 @@ describe API::Settings, 'Settings' do
       expect(json_response['dsa_key_restriction']).to eq(0)
       expect(json_response['ecdsa_key_restriction']).to eq(0)
       expect(json_response['ed25519_key_restriction']).to eq(0)
+      expect(json_response['circuitbreaker_failure_count_threshold']).not_to be_nil
     end
   end
 
@@ -52,7 +53,8 @@ describe API::Settings, 'Settings' do
           rsa_key_restriction: ApplicationSetting::FORBIDDEN_KEY_VALUE,
           dsa_key_restriction: 2048,
           ecdsa_key_restriction: 384,
-          ed25519_key_restriction: 256
+          ed25519_key_restriction: 256,
+          circuitbreaker_failure_wait_time: 2
 
         expect(response).to have_http_status(200)
         expect(json_response['default_projects_limit']).to eq(3)
@@ -73,6 +75,7 @@ describe API::Settings, 'Settings' do
         expect(json_response['dsa_key_restriction']).to eq(2048)
         expect(json_response['ecdsa_key_restriction']).to eq(384)
         expect(json_response['ed25519_key_restriction']).to eq(256)
+        expect(json_response['circuitbreaker_failure_wait_time']).to eq(2)
       end
     end
 
diff --git a/spec/requests/api/v3/repositories_spec.rb b/spec/requests/api/v3/repositories_spec.rb
index 1a55e2a71cd42b977684d6574f6865a69548ebd8..67624a0bbea9a80ec66d17973d5c059d73f59286 100644
--- a/spec/requests/api/v3/repositories_spec.rb
+++ b/spec/requests/api/v3/repositories_spec.rb
@@ -97,10 +97,11 @@ describe API::V3::Repositories do
     end
   end
 
-  {
-    'blobs/:sha' => 'blobs/master',
-    'commits/:sha/blob' => 'commits/master/blob'
-  }.each do |desc_path, example_path|
+  [
+    ['blobs/:sha', 'blobs/master'],
+    ['blobs/:sha', 'blobs/v1.1.0'],
+    ['commits/:sha/blob', 'commits/master/blob']
+  ].each do |desc_path, example_path|
     describe "GET /projects/:id/repository/#{desc_path}" do
       let(:route) { "/projects/#{project.id}/repository/#{example_path}?filepath=README.md" }
       shared_examples_for 'repository blob' do
@@ -110,7 +111,7 @@ describe API::V3::Repositories do
         end
         context 'when sha does not exist' do
           it_behaves_like '404 response' do
-            let(:request) { get v3_api(route.sub('master', 'invalid_branch_name'), current_user) }
+            let(:request) { get v3_api("/projects/#{project.id}/repository/#{desc_path.sub(':sha', 'invalid_branch_name')}?filepath=README.md", current_user) }
             let(:message) { '404 Commit Not Found' }
           end
         end
diff --git a/spec/serializers/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..452754d7a7973272535a7b5752ff52fc2bd6d526
--- /dev/null
+++ b/spec/serializers/group_child_entity_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+describe GroupChildEntity do
+  include Gitlab::Routing.url_helpers
+
+  let(:user) { create(:user) }
+  let(:request) { double('request') }
+  let(:entity) { described_class.new(object, request: request) }
+  subject(:json) { entity.as_json }
+
+  before do
+    allow(request).to receive(:current_user).and_return(user)
+  end
+
+  shared_examples 'group child json' do
+    it 'renders json' do
+      is_expected.not_to be_nil
+    end
+
+    %w[id
+       full_name
+       avatar_url
+       name
+       description
+       visibility
+       type
+       can_edit
+       visibility
+       permission
+       relative_path].each do |attribute|
+      it "includes #{attribute}" do
+        expect(json[attribute.to_sym]).to be_present
+      end
+    end
+  end
+
+  describe 'for a project' do
+    let(:object) do
+      create(:project, :with_avatar,
+             description: 'Awesomeness')
+    end
+
+    before do
+      object.add_master(user)
+    end
+
+    it 'has the correct type' do
+      expect(json[:type]).to eq('project')
+    end
+
+    it 'includes the star count' do
+      expect(json[:star_count]).to be_present
+    end
+
+    it 'has the correct edit path' do
+      expect(json[:edit_path]).to eq(edit_project_path(object))
+    end
+
+    it_behaves_like 'group child json'
+  end
+
+  describe 'for a group', :nested_groups do
+    let(:object) do
+      create(:group, :nested, :with_avatar,
+             description: 'Awesomeness')
+    end
+
+    before do
+      object.add_owner(user)
+    end
+
+    it 'has the correct type' do
+      expect(json[:type]).to eq('group')
+    end
+
+    it 'counts projects and subgroups as children' do
+      create(:project, namespace: object)
+      create(:group, parent: object)
+
+      expect(json[:children_count]).to eq(2)
+    end
+
+    %w[children_count leave_path parent_id number_projects_with_delimiter number_users_with_delimiter project_count subgroup_count].each do |attribute|
+      it "includes #{attribute}" do
+        expect(json[attribute.to_sym]).to be_present
+      end
+    end
+
+    it 'allows an owner to leave when there is another one' do
+      object.add_owner(create(:user))
+
+      expect(json[:can_leave]).to be_truthy
+    end
+
+    it 'has the correct edit path' do
+      expect(json[:edit_path]).to eq(edit_group_path(object))
+    end
+
+    it_behaves_like 'group child json'
+  end
+end
diff --git a/spec/serializers/group_child_serializer_spec.rb b/spec/serializers/group_child_serializer_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5541ada375098455b4e188c6feb8ea1f927834a2
--- /dev/null
+++ b/spec/serializers/group_child_serializer_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+describe GroupChildSerializer do
+  let(:request) { double('request') }
+  let(:user) { create(:user) }
+  subject(:serializer) { described_class.new(current_user: user) }
+
+  describe '#represent' do
+    context 'for groups' do
+      it 'can render a single group' do
+        expect(serializer.represent(build(:group))).to be_kind_of(Hash)
+      end
+
+      it 'can render a collection of groups' do
+        expect(serializer.represent(build_list(:group, 2))).to be_kind_of(Array)
+      end
+    end
+
+    context 'with a hierarchy', :nested_groups do
+      let(:parent) { create(:group) }
+
+      subject(:serializer) do
+        described_class.new(current_user: user).expand_hierarchy(parent)
+      end
+
+      it 'expands the subgroups' do
+        subgroup = create(:group, parent: parent)
+        subsub_group = create(:group, parent: subgroup)
+
+        json = serializer.represent([subgroup, subsub_group]).first
+        subsub_group_json = json[:children].first
+
+        expect(json[:id]).to eq(subgroup.id)
+        expect(subsub_group_json).not_to be_nil
+        expect(subsub_group_json[:id]).to eq(subsub_group.id)
+      end
+
+      it 'can render a nested tree' do
+        subgroup1 = create(:group, parent: parent)
+        subsub_group1 = create(:group, parent: subgroup1)
+        subgroup2 = create(:group, parent: parent)
+
+        json = serializer.represent([subgroup1, subsub_group1, subgroup1, subgroup2])
+        subgroup1_json = json.first
+        subsub_group1_json = subgroup1_json[:children].first
+
+        expect(json.size).to eq(2)
+        expect(subgroup1_json[:id]).to eq(subgroup1.id)
+        expect(subsub_group1_json[:id]).to eq(subsub_group1.id)
+      end
+
+      context 'without a specified parent' do
+        subject(:serializer) do
+          described_class.new(current_user: user).expand_hierarchy
+        end
+
+        it 'can render a tree' do
+          subgroup = create(:group, parent: parent)
+
+          json = serializer.represent([parent, subgroup])
+          parent_json = json.first
+
+          expect(parent_json[:id]).to eq(parent.id)
+          expect(parent_json[:children].first[:id]).to eq(subgroup.id)
+        end
+      end
+    end
+
+    context 'for projects' do
+      it 'can render a single project' do
+        expect(serializer.represent(build(:project))).to be_kind_of(Hash)
+      end
+
+      it 'can render a collection of projects' do
+        expect(serializer.represent(build_list(:project, 2))).to be_kind_of(Array)
+      end
+
+      context 'with a hierarchy', :nested_groups do
+        let(:parent) { create(:group) }
+
+        subject(:serializer) do
+          described_class.new(current_user: user).expand_hierarchy(parent)
+        end
+
+        it 'can render a nested tree' do
+          subgroup1 = create(:group, parent: parent)
+          project1 = create(:project, namespace: subgroup1)
+          subgroup2 = create(:group, parent: parent)
+          project2 = create(:project, namespace: subgroup2)
+
+          json = serializer.represent([project1, project2, subgroup1, subgroup2])
+          project1_json, project2_json = json.map { |group_json| group_json[:children].first }
+
+          expect(json.size).to eq(2)
+          expect(project1_json[:id]).to eq(project1.id)
+          expect(project2_json[:id]).to eq(project2.id)
+        end
+
+        it 'returns an array when an array of a single instance was given' do
+          project = create(:project, namespace: parent)
+
+          json = serializer.represent([project])
+
+          expect(json).to be_kind_of(Array)
+          expect(json.size).to eq(1)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb
index c90bad46295e1c6adf4e3add14f03458567efb04..0bec2054f501cef7f7969b6a9907cbdc2a97b9f2 100644
--- a/spec/services/projects/destroy_service_spec.rb
+++ b/spec/services/projects/destroy_service_spec.rb
@@ -1,6 +1,8 @@
 require 'spec_helper'
 
 describe Projects::DestroyService do
+  include ProjectForksHelper
+
   let!(:user) { create(:user) }
   let!(:project) { create(:project, :repository, namespace: user.namespace) }
   let!(:path) { project.repository.path_to_repo }
@@ -212,6 +214,21 @@ describe Projects::DestroyService do
     end
   end
 
+  context 'for a forked project with LFS objects' do
+    let(:forked_project) { fork_project(project, user) }
+
+    before do
+      project.lfs_objects << create(:lfs_object)
+      forked_project.forked_project_link.destroy
+      forked_project.reload
+    end
+
+    it 'destroys the fork' do
+      expect { destroy_project(forked_project, user) }
+        .not_to raise_error
+    end
+  end
+
   context 'as the root of a fork network' do
     let!(:fork_network) { create(:fork_network, root_project: project) }
 
diff --git a/spec/support/redis_without_keys.rb b/spec/support/redis_without_keys.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6220167dee69661ade0844d8add013527164d075
--- /dev/null
+++ b/spec/support/redis_without_keys.rb
@@ -0,0 +1,8 @@
+class Redis
+  ForbiddenCommand = Class.new(StandardError)
+
+  def keys(*args)
+    raise ForbiddenCommand.new("Don't use `Redis#keys` as it iterates over all "\
+                               "keys in redis. Use `Redis#scan_each` instead.")
+  end
+end
diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb
index 2dfb4d4a07fb9536ac9343d28e02e733b6c392dc..4d448a559780ec40e0f06b813adfb08079cb33ce 100644
--- a/spec/support/stub_configuration.rb
+++ b/spec/support/stub_configuration.rb
@@ -43,10 +43,6 @@ module StubConfiguration
     messages['default'] ||= Gitlab.config.repositories.storages.default
     messages.each do |storage_name, storage_settings|
       storage_settings['path'] = TestEnv.repos_path unless storage_settings.key?('path')
-      storage_settings['failure_count_threshold'] ||= 10
-      storage_settings['failure_wait_time'] ||= 30
-      storage_settings['failure_reset_time'] ||= 1800
-      storage_settings['storage_timeout'] ||= 5
     end
 
     allow(Gitlab.config.repositories).to receive(:storages).and_return(Settingslogic.new(messages))
diff --git a/yarn.lock b/yarn.lock
index 64b27c0556f02c226d56c302c1e1e2c1addae28c..b830278eab03296fb4dbf91419fabb1b0c26eec9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4148,9 +4148,9 @@ moment@2.x:
   version "2.17.1"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.1.tgz#fed9506063f36b10f066c8b59a144d7faebe1d82"
 
-monaco-editor@0.8.3:
-  version "0.8.3"
-  resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.8.3.tgz#523bdf2d1524db2c2dfc3cae0a7b6edc48d6dea6"
+monaco-editor@0.10.0:
+  version "0.10.0"
+  resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.10.0.tgz#6604932585fe9c1f993f000a503d0d20fbe5896a"
 
 mousetrap@^1.4.6:
   version "1.4.6"