Commit 6daf7092 authored by Alessio Caiazza's avatar Alessio Caiazza

Merge commit '96eb6fd3' into 11-1-stable-prepare-rc6

parents c9a5f717 96eb6fd3
...@@ -418,7 +418,7 @@ group :ed25519 do ...@@ -418,7 +418,7 @@ group :ed25519 do
end end
# Gitaly GRPC client # Gitaly GRPC client
gem 'gitaly-proto', '~> 0.103.0', require: 'gitaly' gem 'gitaly-proto', '~> 0.105.0', require: 'gitaly'
gem 'grpc', '~> 1.11.0' gem 'grpc', '~> 1.11.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed # Locked until https://github.com/google/protobuf/issues/4210 is closed
......
...@@ -282,7 +282,7 @@ GEM ...@@ -282,7 +282,7 @@ GEM
gettext_i18n_rails (>= 0.7.1) gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0) po_to_json (>= 1.0.0)
rails (>= 3.2.0) rails (>= 3.2.0)
gitaly-proto (0.103.0) gitaly-proto (0.105.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
grpc (~> 1.10) grpc (~> 1.10)
github-linguist (5.3.3) github-linguist (5.3.3)
...@@ -1037,7 +1037,7 @@ DEPENDENCIES ...@@ -1037,7 +1037,7 @@ DEPENDENCIES
gettext (~> 3.2.2) gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3) gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.103.0) gitaly-proto (~> 0.105.0)
github-linguist (~> 5.3.3) github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1) gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2) gitlab-gollum-lib (~> 4.2)
......
...@@ -285,7 +285,7 @@ GEM ...@@ -285,7 +285,7 @@ GEM
gettext_i18n_rails (>= 0.7.1) gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0) po_to_json (>= 1.0.0)
rails (>= 3.2.0) rails (>= 3.2.0)
gitaly-proto (0.103.0) gitaly-proto (0.105.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
grpc (~> 1.10) grpc (~> 1.10)
github-linguist (5.3.3) github-linguist (5.3.3)
...@@ -1047,7 +1047,7 @@ DEPENDENCIES ...@@ -1047,7 +1047,7 @@ DEPENDENCIES
gettext (~> 3.2.2) gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3) gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.103.0) gitaly-proto (~> 0.105.0)
github-linguist (~> 5.3.3) github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1) gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2) gitlab-gollum-lib (~> 4.2)
......
...@@ -100,12 +100,12 @@ const Api = { ...@@ -100,12 +100,12 @@ const Api = {
}, },
// Return Merge Request for project // Return Merge Request for project
mergeRequest(projectPath, mergeRequestId) { mergeRequest(projectPath, mergeRequestId, params = {}) {
const url = Api.buildUrl(Api.mergeRequestPath) const url = Api.buildUrl(Api.mergeRequestPath)
.replace(':id', encodeURIComponent(projectPath)) .replace(':id', encodeURIComponent(projectPath))
.replace(':mrid', mergeRequestId); .replace(':mrid', mergeRequestId);
return axios.get(url); return axios.get(url, { params });
}, },
mergeRequests(params = {}) { mergeRequests(params = {}) {
......
...@@ -20,16 +20,13 @@ export default { ...@@ -20,16 +20,13 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters(['commit']), ...mapGetters(['commitId']),
normalizedDiffLines() { normalizedDiffLines() {
return this.diffLines.map(line => (line.richText ? trimFirstCharOfLineContent(line) : line)); return this.diffLines.map(line => (line.richText ? trimFirstCharOfLineContent(line) : line));
}, },
diffLinesLength() { diffLinesLength() {
return this.normalizedDiffLines.length; return this.normalizedDiffLines.length;
}, },
commitId() {
return this.commit && this.commit.id;
},
userColorScheme() { userColorScheme() {
return window.gon.user_color_scheme; return window.gon.user_color_scheme;
}, },
......
...@@ -21,7 +21,7 @@ export default { ...@@ -21,7 +21,7 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters(['commit']), ...mapGetters(['commitId']),
parallelDiffLines() { parallelDiffLines() {
return this.diffLines.map(line => { return this.diffLines.map(line => {
const parallelLine = Object.assign({}, line); const parallelLine = Object.assign({}, line);
...@@ -44,9 +44,6 @@ export default { ...@@ -44,9 +44,6 @@ export default {
diffLinesLength() { diffLinesLength() {
return this.parallelDiffLines.length; return this.parallelDiffLines.length;
}, },
commitId() {
return this.commit && this.commit.id;
},
userColorScheme() { userColorScheme() {
return window.gon.user_color_scheme; return window.gon.user_color_scheme;
}, },
......
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants';
export default { export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW_TYPE;
isParallelView(state) {
return state.diffViewType === PARALLEL_DIFF_VIEW_TYPE; export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE;
},
isInlineView(state) { export const areAllFilesCollapsed = state => state.diffFiles.every(file => file.collapsed);
return state.diffViewType === INLINE_DIFF_VIEW_TYPE;
}, export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null);
areAllFilesCollapsed(state) {
return state.diffFiles.every(file => file.collapsed); // prevent babel-plugin-rewire from generating an invalid default during karma tests
}, export default () => {};
commit(state) {
return state.commit;
},
};
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default () => ({
isLoading: true,
endpoint: '',
basePath: '',
commit: null,
diffFiles: [],
mergeRequestDiffs: [],
diffLineCommentForms: {},
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
});
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import actions from '../actions'; import actions from '../actions';
import getters from '../getters'; import * as getters from '../getters';
import mutations from '../mutations'; import mutations from '../mutations';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; import createState from './diff_state';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default { export default {
state: { state: createState(),
isLoading: true,
endpoint: '',
basePath: '',
commit: null,
diffFiles: [],
mergeRequestDiffs: [],
diffLineCommentForms: {},
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
},
getters, getters,
actions, actions,
mutations, mutations,
......
...@@ -66,15 +66,10 @@ export default { ...@@ -66,15 +66,10 @@ export default {
}, },
[types.EXPAND_ALL_FILES](state) { [types.EXPAND_ALL_FILES](state) {
const diffFiles = []; // eslint-disable-next-line no-param-reassign
state.diffFiles = state.diffFiles.map(file => ({
state.diffFiles.forEach(file => {
diffFiles.push({
...file, ...file,
collapsed: false, collapsed: false,
}); }));
});
Object.assign(state, { diffFiles });
}, },
}; };
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AccessorUtilities from '~/lib/utils/accessor';
import eventHub from '../event_hub';
import store from '../store/';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import { isMobile, updateExistingFrequentItem } from '../utils';
import FrequentItemsSearchInput from './frequent_items_search_input.vue';
import FrequentItemsList from './frequent_items_list.vue';
import frequentItemsMixin from './frequent_items_mixin';
export default {
store,
components: {
LoadingIcon,
FrequentItemsSearchInput,
FrequentItemsList,
},
mixins: [frequentItemsMixin],
props: {
currentUserName: {
type: String,
required: true,
},
currentItem: {
type: Object,
required: true,
},
},
computed: {
...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']),
...mapGetters(['hasSearchQuery']),
translations() {
return this.getTranslations(['loadingMessage', 'header']);
},
},
created() {
const { namespace, currentUserName, currentItem } = this;
const storageKey = `${currentUserName}/${STORAGE_KEY[namespace]}`;
this.setNamespace(namespace);
this.setStorageKey(storageKey);
if (currentItem.id) {
this.logItemAccess(storageKey, currentItem);
}
eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
beforeDestroy() {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
methods: {
...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']),
dropdownOpenHandler() {
if (this.searchQuery === '' || isMobile()) {
this.fetchFrequentItems();
}
},
logItemAccess(storageKey, item) {
if (!AccessorUtilities.isLocalStorageAccessSafe()) {
return false;
}
// Check if there's any frequent items list set
const storedRawItems = localStorage.getItem(storageKey);
const storedFrequentItems = storedRawItems
? JSON.parse(storedRawItems)
: [{ ...item, frequency: 1 }]; // No frequent items list set, set one up.
// Check if item already exists in list
const itemMatchIndex = storedFrequentItems.findIndex(
frequentItem => frequentItem.id === item.id,
);
if (itemMatchIndex > -1) {
storedFrequentItems[itemMatchIndex] = updateExistingFrequentItem(
storedFrequentItems[itemMatchIndex],
item,
);
} else {
if (storedFrequentItems.length === FREQUENT_ITEMS.MAX_COUNT) {
storedFrequentItems.shift();
}
storedFrequentItems.push({ ...item, frequency: 1 });
}
return localStorage.setItem(storageKey, JSON.stringify(storedFrequentItems));
},
},
};
</script>
<template>
<div>
<frequent-items-search-input
:namespace="namespace"
/>
<loading-icon
v-if="isLoadingItems"
:label="translations.loadingMessage"
class="loading-animation prepend-top-20"
size="2"
/>
<div
v-if="!isLoadingItems && !hasSearchQuery"
class="section-header"
>
{{ translations.header }}
</div>
<frequent-items-list
v-if="!isLoadingItems"
:items="items"
:namespace="namespace"
:has-search-query="hasSearchQuery"
:is-fetch-failed="isFetchFailed"
:matcher="searchQuery"
/>
</div>
</template>
<script> <script>
import { s__ } from '../../locale'; import FrequentItemsListItem from './frequent_items_list_item.vue';
import projectsListItem from './projects_list_item.vue'; import frequentItemsMixin from './frequent_items_mixin';
export default { export default {
components: { components: {
projectsListItem, FrequentItemsListItem,
}, },
mixins: [frequentItemsMixin],
props: { props: {
matcher: { items: {
type: String, type: Array,
required: true, required: true,
}, },
projects: { hasSearchQuery: {
type: Array, type: Boolean,
required: true, required: true,
}, },
searchFailed: { isFetchFailed: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
matcher: {
type: String,
required: true,
},
}, },
computed: { computed: {
translations() {
return this.getTranslations([
'itemListEmptyMessage',
'itemListErrorMessage',
'searchListEmptyMessage',
'searchListErrorMessage',
]);
},
isListEmpty() { isListEmpty() {
return this.projects.length === 0; return this.items.length === 0;
}, },
listEmptyMessage() { listEmptyMessage() {
return this.searchFailed ? if (this.hasSearchQuery) {
s__('ProjectsDropdown|Something went wrong on our end.') : return this.isFetchFailed
s__('ProjectsDropdown|Sorry, no projects matched your search'); ? this.translations.searchListErrorMessage
: this.translations.searchListEmptyMessage;
}
return this.isFetchFailed
? this.translations.itemListErrorMessage
: this.translations.itemListEmptyMessage;
}, },
}, },
}; };
</script> </script>
<template> <template>
<div <div class="frequent-items-list-container">
class="projects-list-search-container" <ul class="list-unstyled">
>
<ul
class="list-unstyled"
>
<li <li
v-if="isListEmpty" v-if="isListEmpty"
:class="{ 'section-failure': searchFailed }" :class="{ 'section-failure': isFetchFailed }"
class="section-empty" class="section-empty"
> >
{{ listEmptyMessage }} {{ listEmptyMessage }}
</li> </li>
<projects-list-item <frequent-items-list-item
v-for="(project, index) in projects" v-for="item in items"
v-else v-else
:key="index" :key="item.id"
:project-id="project.id" :item-id="item.id"
:project-name="project.name" :item-name="item.name"
:namespace="project.namespace" :namespace="item.namespace"
:web-url="project.webUrl" :web-url="item.webUrl"
:avatar-url="project.avatarUrl" :avatar-url="item.avatarUrl"
:matcher="matcher" :matcher="matcher"
/> />
</ul> </ul>
......
<script>
/* eslint-disable vue/require-default-prop, vue/require-prop-types */
import Identicon from '../../vue_shared/components/identicon.vue';
export default {
components: {
Identicon,
},
props: {
matcher: {
type: String,
required: false,
},
itemId: {
type: Number,
required: true,
},
itemName: {
type: String,
required: true,
},
namespace: {
type: String,
required: false,
},
webUrl: {
type: String,
required: true,
},
avatarUrl: {
required: true,
validator(value) {
return value === null || typeof value === 'string';
},
},
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
},
highlightedItemName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.itemName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.itemName;
},
/**
* Smartly truncates item namespace by doing two things;
* 1. Only include Group names in path by removing item name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of item name from namespace) can be
* done from backend but doing so involves migration of
* existing item namespaces which is not wise thing to do.
*/
truncatedNamespace() {
if (!this.namespace) {
return null;
}
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
},
},
};
</script>
<template>
<li class="frequent-items-list-item-container">
<a
:href="webUrl"
class="clearfix"
>
<div class="frequent-items-item-avatar-container">
<img
v-if="hasAvatar"
:src="avatarUrl"
class="avatar s32"
/>
<identicon
v-else
:entity-id="itemId"
:entity-name="itemName"
size-class="s32"
/>
</div>
<div class="frequent-items-item-metadata-container">
<div
:title="itemName"
class="frequent-items-item-title"
v-html="highlightedItemName"
>
</div>
<div
v-if="truncatedNamespace"
:title="namespace"
class="frequent-items-item-namespace"
>
{{ truncatedNamespace }}
</div>
</div>
</a>
</li>
</template>
import { TRANSLATION_KEYS } from '../constants';
export default {
props: {
namespace: {
type: String,
required: true,
},
},
methods: {
getTranslations(keys) {
const translationStrings = keys.reduce(
(acc, key) => ({
...acc,
[key]: TRANSLATION_KEYS[this.namespace][key],
}),
{},
);
return translationStrings;
},
},
};
<script>
import _ from 'underscore';
import { mapActions } from 'vuex';
import eventHub from '../event_hub';
import frequentItemsMixin from './frequent_items_mixin';
export default {
mixins: [frequentItemsMixin],
data() {
return {
searchQuery: '',
};
},
computed: {
translations() {
return this.getTranslations(['searchInputPlaceholder']);
},
},
watch: {
searchQuery: _.debounce(function debounceSearchQuery() {
this.setSearchQuery(this.searchQuery);
}, 500),
},
mounted() {
eventHub.$on(`${this.namespace}-dropdownOpen`, this.setFocus);
},
beforeDestroy() {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.setFocus);
},
methods: {
...mapActions(['setSearchQuery']),
setFocus() {
this.$refs.search.focus();
},
},
};
</script>
<template>
<div class="search-input-container d-none d-sm-block">
<input
ref="search"
v-model="searchQuery"
:placeholder="translations.searchInputPlaceholder"
type="search"
class="form-control"
/>
<i
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
>
</i>
</div>
</template>
import { s__ } from '~/locale';
export const FREQUENT_ITEMS = {
MAX_COUNT: 20,
LIST_COUNT_DESKTOP: 5,
LIST_COUNT_MOBILE: 3,
ELIGIBLE_FREQUENCY: 3,
};
export const HOUR_IN_MS = 3600000;
export const STORAGE_KEY = {
projects: 'frequent-projects',
groups: 'frequent-groups',
};
export const TRANSLATION_KEYS = {
projects: {
loadingMessage: s__('ProjectsDropdown|Loading projects'),
header: s__('ProjectsDropdown|Frequently visited'),
itemListErrorMessage: s__(
'ProjectsDropdown|This feature requires browser localStorage support',
),
itemListEmptyMessage: s__('ProjectsDropdown|Projects you visit often will appear here'),
searchListErrorMessage: s__('ProjectsDropdown|Something went wrong on our end.'),
searchListEmptyMessage: s__('ProjectsDropdown|Sorry, no projects matched your search'),
searchInputPlaceholder: s__('ProjectsDropdown|Search your projects'),
},
groups: {
loadingMessage: s__('GroupsDropdown|Loading groups'),
header: s__('GroupsDropdown|Frequently visited'),
itemListErrorMessage: s__('GroupsDropdown|This feature requires browser localStorage support'),
itemListEmptyMessage: s__('GroupsDropdown|Groups you visit often will appear here'),
searchListErrorMessage: s__('GroupsDropdown|Something went wrong on our end.'),
searchListEmptyMessage: s__('GroupsDropdown|Sorry, no groups matched your search'),
searchInputPlaceholder: s__('GroupsDropdown|Search your groups'),
},
};
import $ from 'jquery';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import eventHub from '~/frequent_items/event_hub';
import frequentItems from './components/app.vue';
Vue.use(Translate);
const frequentItemDropdowns = [
{
namespace: 'projects',
key: 'project',
},
{
namespace: 'groups',
key: 'group',
},
];
document.addEventListener('DOMContentLoaded', () => {
frequentItemDropdowns.forEach(dropdown => {
const { namespace, key } = dropdown;
const el = document.getElementById(`js-${namespace}-dropdown`);
const navEl = document.getElementById(`nav-${namespace}-dropdown`);
// Don't do anything if element doesn't exist (No groups dropdown)
// This is for when the user accesses GitLab without logging in
if (!el || !navEl) {
return;
}
$(navEl).on('shown.bs.dropdown', () => {
eventHub.$emit(`${namespace}-dropdownOpen`);
});
// eslint-disable-next-line no-new
new Vue({
el,
components: {
frequentItems,
},
data() {
const { dataset } = this.$options.el;
const item = {
id: Number(dataset[`${key}Id`]),
name: dataset[`${key}Name`],
namespace: dataset[`${key}Namespace`],
webUrl: dataset[`${key}WebUrl`],
avatarUrl: dataset[`${key}AvatarUrl`] || null,
lastAccessedOn: Date.now(),
};
return {
currentUserName: dataset.userName,
currentItem: item,
};
},
render(createElement) {
return createElement('frequent-items', {
props: {
namespace,
currentUserName: this.currentUserName,
currentItem: this.currentItem,
},
});
},
});
});
});
import Api from '~/api';
import AccessorUtilities from '~/lib/utils/accessor';
import * as types from './mutation_types';
import { getTopFrequentItems } from '../utils';
export const setNamespace = ({ commit }, namespace) => {
commit(types.SET_NAMESPACE, namespace);
};
export const setStorageKey = ({ commit }, key) => {
commit(types.SET_STORAGE_KEY, key);
};
export const requestFrequentItems = ({ commit }) => {
commit(types.REQUEST_FREQUENT_ITEMS);
};
export const receiveFrequentItemsSuccess = ({ commit }, data) => {
commit(types.RECEIVE_FREQUENT_ITEMS_SUCCESS, data);
};
export const receiveFrequentItemsError = ({ commit }) => {
commit(types.RECEIVE_FREQUENT_ITEMS_ERROR);
};
export const fetchFrequentItems = ({ state, dispatch }) => {
dispatch('requestFrequentItems');
if (AccessorUtilities.isLocalStorageAccessSafe()) {
const storedFrequentItems = JSON.parse(localStorage.getItem(state.storageKey));
dispatch(
'receiveFrequentItemsSuccess',
!storedFrequentItems ? [] : getTopFrequentItems(storedFrequentItems),
);
} else {
dispatch('receiveFrequentItemsError');
}
};
export const requestSearchedItems = ({ commit }) => {
commit(types.REQUEST_SEARCHED_ITEMS);
};
export const receiveSearchedItemsSuccess = ({ commit }, data) => {
commit(types.RECEIVE_SEARCHED_ITEMS_SUCCESS, data);
};
export const receiveSearchedItemsError = ({ commit }) => {
commit(types.RECEIVE_SEARCHED_ITEMS_ERROR);
};
export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => {
dispatch('requestSearchedItems');
const params = {
simple: true,
per_page: 20,
membership: !!gon.current_user_id,
};
if (state.namespace === 'projects') {
params.order_by = 'last_activity_at';
}
return Api[state.namespace](searchQuery, params)
.then(results => {
dispatch('receiveSearchedItemsSuccess', results);
})
.catch(() => {
dispatch('receiveSearchedItemsError');
});
};
export const setSearchQuery = ({ commit, dispatch }, query) => {
commit(types.SET_SEARCH_QUERY, query);
if (query) {
dispatch('fetchSearchedItems', query);
} else {
dispatch('fetchFrequentItems');
}
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const hasSearchQuery = state => state.searchQuery !== '';
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
getters,
mutations,
state: state(),
});
export const SET_NAMESPACE = 'SET_NAMESPACE';
export const SET_STORAGE_KEY = 'SET_STORAGE_KEY';
export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const REQUEST_FREQUENT_ITEMS = 'REQUEST_FREQUENT_ITEMS';
export const RECEIVE_FREQUENT_ITEMS_SUCCESS = 'RECEIVE_FREQUENT_ITEMS_SUCCESS';
export const RECEIVE_FREQUENT_ITEMS_ERROR = 'RECEIVE_FREQUENT_ITEMS_ERROR';
export const REQUEST_SEARCHED_ITEMS = 'REQUEST_SEARCHED_ITEMS';
export const RECEIVE_SEARCHED_ITEMS_SUCCESS = 'RECEIVE_SEARCHED_ITEMS_SUCCESS';
export const RECEIVE_SEARCHED_ITEMS_ERROR = 'RECEIVE_SEARCHED_ITEMS_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_NAMESPACE](state, namespace) {
Object.assign(state, {
namespace,
});
},
[types.SET_STORAGE_KEY](state, storageKey) {
Object.assign(state, {
storageKey,
});
},
[types.SET_SEARCH_QUERY](state, searchQuery) {
const hasSearchQuery = searchQuery !== '';
Object.assign(state, {
searchQuery,
isLoadingItems: true,
hasSearchQuery,
});
},
[types.REQUEST_FREQUENT_ITEMS](state) {
Object.assign(state, {
isLoadingItems: true,
hasSearchQuery: false,
});
},
[types.RECEIVE_FREQUENT_ITEMS_SUCCESS](state, rawItems) {
Object.assign(state, {
items: rawItems,
isLoadingItems: false,
hasSearchQuery: false,
isFetchFailed: false,
});
},
[types.RECEIVE_FREQUENT_ITEMS_ERROR](state) {
Object.assign(state, {
isLoadingItems: false,
hasSearchQuery: false,
isFetchFailed: true,
});
},
[types.REQUEST_SEARCHED_ITEMS](state) {
Object.assign(state, {
isLoadingItems: true,
hasSearchQuery: true,
});
},
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) {
Object.assign(state, {
items: rawItems.map(rawItem => ({
id: rawItem.id,
name: rawItem.name,
namespace: rawItem.name_with_namespace || rawItem.full_name,
webUrl: rawItem.web_url,
avatarUrl: rawItem.avatar_url,
})),
isLoadingItems: false,
hasSearchQuery: true,
isFetchFailed: false,
});
},
[types.RECEIVE_SEARCHED_ITEMS_ERROR](state) {
Object.assign(state, {
isLoadingItems: false,
hasSearchQuery: true,
isFetchFailed: true,
});
},
};
export default () => ({
namespace: '',
storageKey: '',
searchQuery: '',
isLoadingItems: false,
isFetchFailed: false,
items: [],
});
import _ from 'underscore';
import bp from '~/breakpoints';
import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants';
export const isMobile = () => {
const screenSize = bp.getBreakpointSize();
return screenSize === 'sm' || screenSize === 'xs';
};
export const getTopFrequentItems = items => {
if (!items) {
return [];
}
const frequentItemsCount = isMobile()
? FREQUENT_ITEMS.LIST_COUNT_MOBILE
: FREQUENT_ITEMS.LIST_COUNT_DESKTOP;
const frequentItems = items.filter(item => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY);
if (!frequentItems || frequentItems.length === 0) {
return [];
}
frequentItems.sort((itemA, itemB) => {
// Sort all frequent items in decending order of frequency
// and then by lastAccessedOn with recent most first
if (itemA.frequency !== itemB.frequency) {
return itemB.frequency - itemA.frequency;
} else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) {
return itemB.lastAccessedOn - itemA.lastAccessedOn;
}
return 0;
});
return _.first(frequentItems, frequentItemsCount);
};
export const updateExistingFrequentItem = (frequentItem, item) => {
const accessedOverHourAgo =
Math.abs(item.lastAccessedOn - frequentItem.lastAccessedOn) / HOUR_IN_MS > 1;
return {
...item,
frequency: accessedOverHourAgo ? frequentItem.frequency + 1 : frequentItem.frequency,
lastAccessedOn: accessedOverHourAgo ? Date.now() : frequentItem.lastAccessedOn,
};
};
<script>
import { mapGetters } from 'vuex';
import Icon from '../../../vue_shared/components/icon.vue';
import TitleComponent from '../../../issue_show/components/title.vue';
import DescriptionComponent from '../../../issue_show/components/description.vue';
export default {
components: {
Icon,
TitleComponent,
DescriptionComponent,
},
computed: {
...mapGetters(['currentMergeRequest']),
},
};
</script>
<template>
<div class="ide-merge-request-info h-100 d-flex flex-column">
<div class="detail-page-header">
<icon
name="git-merge"
class="align-self-center append-right-8"
/>
<strong>
!{{ currentMergeRequest.iid }}
</strong>
</div>
<div class="issuable-details">
<title-component
:issuable-ref="currentMergeRequest.iid"
:title-html="currentMergeRequest.title_html"
:title-text="currentMergeRequest.title"
/>
<description-component
:description-html="currentMergeRequest.description_html"
:description-text="currentMergeRequest.description"
:can-update="false"
/>
</div>
</div>
</template>
...@@ -5,6 +5,7 @@ import Icon from '../../../vue_shared/components/icon.vue'; ...@@ -5,6 +5,7 @@ import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants'; import { rightSidebarViews } from '../../constants';
import PipelinesList from '../pipelines/list.vue'; import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue'; import JobsDetail from '../jobs/detail.vue';
import MergeRequestInfo from '../merge_requests/info.vue';
import ResizablePanel from '../resizable_panel.vue'; import ResizablePanel from '../resizable_panel.vue';
export default { export default {
...@@ -16,9 +17,10 @@ export default { ...@@ -16,9 +17,10 @@ export default {
PipelinesList, PipelinesList,
JobsDetail, JobsDetail,
ResizablePanel, ResizablePanel,
MergeRequestInfo,
}, },
computed: { computed: {
...mapState(['rightPane']), ...mapState(['rightPane', 'currentMergeRequestId']),
pipelinesActive() { pipelinesActive() {
return ( return (
this.rightPane === rightSidebarViews.pipelines || this.rightPane === rightSidebarViews.pipelines ||
...@@ -54,10 +56,33 @@ export default { ...@@ -54,10 +56,33 @@ export default {
</resizable-panel> </resizable-panel>
<nav class="ide-activity-bar"> <nav class="ide-activity-bar">
<ul class="list-unstyled"> <ul class="list-unstyled">
<li
v-if="currentMergeRequestId"
>
<button
v-tooltip
:title="__('Merge Request')"
:aria-label="__('Merge Request')"
:class="{
active: rightPane === $options.rightSidebarViews.mergeRequestInfo
}"
data-container="body"
data-placement="left"
class="ide-sidebar-link is-right"
type="button"
@click="clickTab($event, $options.rightSidebarViews.mergeRequestInfo)"
>
<icon
:size="16"
name="text-description"
/>
</button>
</li>
<li> <li>
<button <button
v-tooltip v-tooltip
:title="__('Pipelines')" :title="__('Pipelines')"
:aria-label="__('Pipelines')"
:class="{ :class="{
active: pipelinesActive active: pipelinesActive
}" }"
......
...@@ -31,6 +31,7 @@ export const diffModes = { ...@@ -31,6 +31,7 @@ export const diffModes = {
export const rightSidebarViews = { export const rightSidebarViews = {
pipelines: 'pipelines-list', pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail', jobsDetail: 'jobs-detail',
mergeRequestInfo: 'merge-request-info',
}; };
export const stageKeys = { export const stageKeys = {
......
...@@ -40,8 +40,8 @@ export default { ...@@ -40,8 +40,8 @@ export default {
getProjectData(namespace, project) { getProjectData(namespace, project) {
return Api.project(`${namespace}/${project}`); return Api.project(`${namespace}/${project}`);
}, },
getProjectMergeRequestData(projectId, mergeRequestId) { getProjectMergeRequestData(projectId, mergeRequestId, params = {}) {
return Api.mergeRequest(projectId, mergeRequestId); return Api.mergeRequest(projectId, mergeRequestId, params);
}, },
getProjectMergeRequestChanges(projectId, mergeRequestId) { getProjectMergeRequestChanges(projectId, mergeRequestId) {
return Api.mergeRequestChanges(projectId, mergeRequestId); return Api.mergeRequestChanges(projectId, mergeRequestId);
......
...@@ -9,7 +9,7 @@ export const getMergeRequestData = ( ...@@ -9,7 +9,7 @@ export const getMergeRequestData = (
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) {
service service
.getProjectMergeRequestData(projectId, mergeRequestId) .getProjectMergeRequestData(projectId, mergeRequestId, { render_html: true })
.then(({ data }) => { .then(({ data }) => {
commit(types.SET_MERGE_REQUEST, { commit(types.SET_MERGE_REQUEST, {
projectPath: projectId, projectPath: projectId,
......
...@@ -108,6 +108,11 @@ ...@@ -108,6 +108,11 @@
type: String, type: String,
required: true, required: true,
}, },
markdownVersion: {
type: Number,
required: false,
default: 0,
},
projectPath: { projectPath: {
type: String, type: String,
required: true, required: true,
...@@ -282,6 +287,7 @@ ...@@ -282,6 +287,7 @@
:issuable-templates="issuableTemplates" :issuable-templates="issuableTemplates"
:markdown-docs-path="markdownDocsPath" :markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:markdown-version="markdownVersion"
:project-path="projectPath" :project-path="projectPath"
:project-namespace="projectNamespace" :project-namespace="projectNamespace"
:show-delete-button="showDeleteButton" :show-delete-button="showDeleteButton"
......
...@@ -20,6 +20,11 @@ ...@@ -20,6 +20,11 @@
type: String, type: String,
required: true, required: true,
}, },
markdownVersion: {
type: Number,
required: false,
default: 0,
},
canAttachFile: { canAttachFile: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -47,6 +52,7 @@ ...@@ -47,6 +52,7 @@
<markdown-field <markdown-field
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath" :markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:can-attach-file="canAttachFile" :can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete" :enable-autocomplete="enableAutocomplete"
> >
......
...@@ -35,6 +35,11 @@ ...@@ -35,6 +35,11 @@
type: String, type: String,
required: true, required: true,
}, },
markdownVersion: {
type: Number,
required: false,
default: 0,
},
projectPath: { projectPath: {
type: String, type: String,
required: true, required: true,
...@@ -97,6 +102,7 @@ ...@@ -97,6 +102,7 @@
:form-state="formState" :form-state="formState"
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath" :markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:can-attach-file="canAttachFile" :can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete" :enable-autocomplete="enableAutocomplete"
/> />
......
<script> <script>
import animateMixin from '../mixins/animate'; import animateMixin from '../mixins/animate';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
import { spriteIcon } from '../../lib/utils/common_utils'; import { spriteIcon } from '../../lib/utils/common_utils';
export default { export default {
directives: { directives: {
tooltip, tooltip,
}, },
mixins: [animateMixin], mixins: [animateMixin],
props: { props: {
issuableRef: { issuableRef: {
type: String, type: [String, Number],
required: true, required: true,
}, },
canUpdate: { canUpdate: {
...@@ -61,7 +61,7 @@ ...@@ -61,7 +61,7 @@
eventHub.$emit('open.form'); eventHub.$emit('open.form');
}, },
}, },
}; };
</script> </script>
<template> <template>
......
...@@ -26,7 +26,7 @@ import './feature_highlight/feature_highlight_options'; ...@@ -26,7 +26,7 @@ import './feature_highlight/feature_highlight_options';
import LazyLoader from './lazy_loader'; import LazyLoader from './lazy_loader';
import initLogoAnimation from './logo'; import initLogoAnimation from './logo';
import './milestone_select'; import './milestone_select';
import './projects_dropdown'; import './frequent_items';
import initBreadcrumbs from './breadcrumb'; import initBreadcrumbs from './breadcrumb';
import initDispatcher from './dispatcher'; import initDispatcher from './dispatcher';
......
...@@ -1251,13 +1251,15 @@ export default class Notes { ...@@ -1251,13 +1251,15 @@ export default class Notes {
var postUrl = $originalContentEl.data('postUrl'); var postUrl = $originalContentEl.data('postUrl');
var targetId = $originalContentEl.data('targetId'); var targetId = $originalContentEl.data('targetId');
var targetType = $originalContentEl.data('targetType'); var targetType = $originalContentEl.data('targetType');
var markdownVersion = $originalContentEl.data('markdownVersion');
this.glForm = new GLForm($editForm.find('form'), this.enableGFM); this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
$editForm $editForm
.find('form') .find('form')
.attr('action', `${postUrl}?html=true`) .attr('action', `${postUrl}?html=true`)
.attr('data-remote', 'true'); .attr('data-remote', 'true')
.attr('data-markdown-version', markdownVersion);
$editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType); $editForm.find('.js-form-target-type').val(targetType);
$editForm $editForm
......
...@@ -34,6 +34,11 @@ export default { ...@@ -34,6 +34,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
markdownVersion: {
type: Number,
required: false,
default: 0,
},
}, },
data() { data() {
return { return {
...@@ -344,6 +349,7 @@ Please check your network connection and try again.`; ...@@ -344,6 +349,7 @@ Please check your network connection and try again.`;
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath" :markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath" :quick-actions-docs-path="quickActionsDocsPath"
:markdown-version="markdownVersion"
:add-spacing-classes="false"> :add-spacing-classes="false">
<textarea <textarea
id="note-body" id="note-body"
......
...@@ -92,6 +92,7 @@ export default { ...@@ -92,6 +92,7 @@ export default {
:is-editing="isEditing" :is-editing="isEditing"
:note-body="noteBody" :note-body="noteBody"
:note-id="note.id" :note-id="note.id"
:markdown-version="note.cached_markdown_version"
@handleFormUpdate="handleFormUpdate" @handleFormUpdate="handleFormUpdate"
@cancelForm="formCancelHandler" @cancelForm="formCancelHandler"
/> />
......
...@@ -24,6 +24,11 @@ export default { ...@@ -24,6 +24,11 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
markdownVersion: {
type: Number,
required: false,
default: 0,
},
saveButtonTitle: { saveButtonTitle: {
type: String, type: String,
required: false, required: false,
...@@ -156,6 +161,7 @@ export default { ...@@ -156,6 +161,7 @@ export default {
<markdown-field <markdown-field
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath" :markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:quick-actions-docs-path="quickActionsDocsPath" :quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"> :add-spacing-classes="false">
<textarea <textarea
......
...@@ -43,6 +43,11 @@ export default { ...@@ -43,6 +43,11 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
markdownVersion: {
type: Number,
required: false,
default: 0,
},
}, },
data() { data() {
return { return {
...@@ -192,6 +197,7 @@ export default { ...@@ -192,6 +197,7 @@ export default {
<comment-form <comment-form
:noteable-type="noteableType" :noteable-type="noteableType"
:markdown-version="markdownVersion"
/> />
</div> </div>
</template> </template>
...@@ -15,6 +15,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -15,6 +15,7 @@ document.addEventListener('DOMContentLoaded', () => {
const notesDataset = document.getElementById('js-vue-notes').dataset; const notesDataset = document.getElementById('js-vue-notes').dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData); const parsedUserData = JSON.parse(notesDataset.currentUserData);
const noteableData = JSON.parse(notesDataset.noteableData); const noteableData = JSON.parse(notesDataset.noteableData);
const { markdownVersion } = notesDataset;
let currentUserData = {}; let currentUserData = {};
noteableData.noteableType = notesDataset.noteableType; noteableData.noteableType = notesDataset.noteableType;
...@@ -33,6 +34,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -33,6 +34,7 @@ document.addEventListener('DOMContentLoaded', () => {
return { return {
noteableData, noteableData,
currentUserData, currentUserData,
markdownVersion,
notesData: JSON.parse(notesDataset.notesData), notesData: JSON.parse(notesDataset.notesData),
}; };
}, },
...@@ -42,6 +44,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -42,6 +44,7 @@ document.addEventListener('DOMContentLoaded', () => {
noteableData: this.noteableData, noteableData: this.noteableData,
notesData: this.notesData, notesData: this.notesData,
userData: this.currentUserData, userData: this.currentUserData,
markdownVersion: this.markdownVersion,
}, },
}); });
}, },
......
import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
document.addEventListener('DOMContentLoaded', () => {
const input = document.querySelector('.js-add-ssh-key-validation-input');
const warning = document.querySelector('.js-add-ssh-key-validation-warning');
const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit');
const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit');
const addSshKeyValidation = new AddSshKeyValidation(
input,
warning,
originalSubmit,
confirmSubmit,
);
addSshKeyValidation.register();
});
...@@ -28,12 +28,16 @@ MarkdownPreview.prototype.ajaxCache = {}; ...@@ -28,12 +28,16 @@ MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function ($form) { MarkdownPreview.prototype.showPreview = function ($form) {
var mdText; var mdText;
var markdownVersion;
var url;
var preview = $form.find('.js-md-preview'); var preview = $form.find('.js-md-preview');
var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) { if (preview.hasClass('md-preview-loading')) {
return; return;
} }
mdText = $form.find('textarea.markdown-area').val(); mdText = $form.find('textarea.markdown-area').val();
markdownVersion = $form.attr('data-markdown-version');
url = this.versionedPreviewPath(preview.data('url'), markdownVersion);
if (mdText.trim().length === 0) { if (mdText.trim().length === 0) {
preview.text(this.emptyMessage); preview.text(this.emptyMessage);
...@@ -59,6 +63,14 @@ MarkdownPreview.prototype.showPreview = function ($form) { ...@@ -59,6 +63,14 @@ MarkdownPreview.prototype.showPreview = function ($form) {
} }
}; };
MarkdownPreview.prototype.versionedPreviewPath = function (markdownPreviewPath, markdownVersion) {
if (typeof markdownVersion === 'undefined') {
return markdownPreviewPath;
}
return `${markdownPreviewPath}${markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'}markdown_version=${markdownVersion}`;
};
MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
if (!url) { if (!url) {
return; return;
......
export default class AddSshKeyValidation {
constructor(inputElement, warningElement, originalSubmitElement, confirmSubmitElement) {
this.inputElement = inputElement;
this.form = inputElement.form;
this.warningElement = warningElement;
this.originalSubmitElement = originalSubmitElement;
this.confirmSubmitElement = confirmSubmitElement;
this.isValid = false;
}
register() {
this.form.addEventListener('submit', event => this.submit(event));
this.confirmSubmitElement.addEventListener('click', () => {
this.isValid = true;
this.form.submit();
});
this.inputElement.addEventListener('input', () => this.toggleWarning(false));
}
submit(event) {
this.isValid = AddSshKeyValidation.isPublicKey(this.inputElement.value);
if (this.isValid) return true;
event.preventDefault();
this.toggleWarning(true);
return false;
}
toggleWarning(isVisible) {
this.warningElement.classList.toggle('hide', !isVisible);
this.originalSubmitElement.classList.toggle('hide', isVisible);
}
static isPublicKey(value) {
return /^(ssh|ecdsa-sha2)-/.test(value);
}
}
<script>
import bs from '../../breakpoints';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import projectsListFrequent from './projects_list_frequent.vue';
import projectsListSearch from './projects_list_search.vue';
import search from './search.vue';
export default {
components: {
search,
loadingIcon,
projectsListFrequent,
projectsListSearch,
},
props: {
currentProject: {
type: Object,
required: true,
},
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
isLoadingProjects: false,
isFrequentsListVisible: false,
isSearchListVisible: false,
isLocalStorageFailed: false,
isSearchFailed: false,
searchQuery: '',
};
},
computed: {
frequentProjects() {
return this.store.getFrequentProjects();
},
searchProjects() {
return this.store.getSearchedProjects();
},
},
created() {
if (this.currentProject.id) {
this.logCurrentProjectAccess();
}
eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
eventHub.$on('searchProjects', this.fetchSearchedProjects);
eventHub.$on('searchCleared', this.handleSearchClear);
eventHub.$on('searchFailed', this.handleSearchFailure);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
eventHub.$off('searchProjects', this.fetchSearchedProjects);
eventHub.$off('searchCleared', this.handleSearchClear);
eventHub.$off('searchFailed', this.handleSearchFailure);
},
methods: {
toggleFrequentProjectsList(state) {
this.isLoadingProjects = !state;
this.isSearchListVisible = !state;
this.isFrequentsListVisible = state;
},
toggleSearchProjectsList(state) {
this.isLoadingProjects = !state;
this.isFrequentsListVisible = !state;
this.isSearchListVisible = state;
},
toggleLoader(state) {
this.isFrequentsListVisible = !state;
this.isSearchListVisible = !state;
this.isLoadingProjects = state;
},
fetchFrequentProjects() {
const screenSize = bs.getBreakpointSize();
if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) {
this.toggleSearchProjectsList(true);
} else {
this.toggleLoader(true);
this.isLocalStorageFailed = false;
const projects = this.service.getFrequentProjects();
if (projects) {
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects(projects);
} else {
this.isLocalStorageFailed = true;
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects([]);
}
}
},
fetchSearchedProjects(searchQuery) {
this.searchQuery = searchQuery;
this.toggleLoader(true);
this.service
.getSearchedProjects(this.searchQuery)
.then(res => res.json())
.then(results => {
this.toggleSearchProjectsList(true);
this.store.setSearchedProjects(results);
})
.catch(() => {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
});
},
logCurrentProjectAccess() {
this.service.logProjectAccess(this.currentProject);
},
handleSearchClear() {
this.searchQuery = '';
this.toggleFrequentProjectsList(true);
this.store.clearSearchedProjects();
},
handleSearchFailure() {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
},
},
};
</script>
<template>
<div>
<search/>
<loading-icon
v-if="isLoadingProjects"
:label="s__('ProjectsDropdown|Loading projects')"
class="loading-animation prepend-top-20"
size="2"
/>
<div
v-if="isFrequentsListVisible"
class="section-header"
>
{{ s__('ProjectsDropdown|Frequently visited') }}
</div>
<projects-list-frequent
v-if="isFrequentsListVisible"
:local-storage-failed="isLocalStorageFailed"
:projects="frequentProjects"
/>
<projects-list-search
v-if="isSearchListVisible"
:search-failed="isSearchFailed"
:matcher="searchQuery"
:projects="searchProjects"
/>
</div>
</template>
<script>
import { s__ } from '../../locale';
import projectsListItem from './projects_list_item.vue';
export default {
components: {
projectsListItem,
},
props: {
projects: {
type: Array,
required: true,
},
localStorageFailed: {
type: Boolean,
required: true,
},
},
computed: {
isListEmpty() {
return this.projects.length === 0;
},
listEmptyMessage() {
return this.localStorageFailed ?
s__('ProjectsDropdown|This feature requires browser localStorage support') :
s__('ProjectsDropdown|Projects you visit often will appear here');
},
},
};
</script>
<template>
<div
class="projects-list-frequent-container"
>
<ul
class="list-unstyled"
>
<li
v-if="isListEmpty"
class="section-empty"
>
{{ listEmptyMessage }}
</li>
<projects-list-item
v-for="(project, index) in projects"
v-else
:key="index"
:project-id="project.id"
:project-name="project.name"
:namespace="project.namespace"
:web-url="project.webUrl"
:avatar-url="project.avatarUrl"
/>
</ul>
</div>
</template>
<script>
/* eslint-disable vue/require-default-prop, vue/require-prop-types */
import identicon from '../../vue_shared/components/identicon.vue';
export default {
components: {
identicon,
},
props: {
matcher: {
type: String,
required: false,
},
projectId: {
type: Number,
required: true,
},
projectName: {
type: String,
required: true,
},
namespace: {
type: String,
required: true,
},
webUrl: {
type: String,
required: true,
},
avatarUrl: {
required: true,
validator(value) {
return value === null || typeof value === 'string';
},
},
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
},
highlightedProjectName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.projectName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.projectName;
},
/**
* Smartly truncates project namespace by doing two things;
* 1. Only include Group names in path by removing project name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of project name from namespace) can be
* done from backend but doing so involves migration of
* existing project namespaces which is not wise thing to do.
*/
truncatedNamespace() {
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
},
},
};
</script>
<template>
<li
class="projects-list-item-container"
>
<a
:href="webUrl"
class="clearfix"
>
<div
class="project-item-avatar-container"
>
<img
v-if="hasAvatar"
:src="avatarUrl"
class="avatar s32"
/>
<identicon
v-else
:entity-id="projectId"
:entity-name="projectName"
size-class="s32"
/>
</div>
<div
class="project-item-metadata-container"
>
<div
:title="projectName"
class="project-title"
v-html="highlightedProjectName"
>
</div>
<div
:title="namespace"
class="project-namespace"
>{{ truncatedNamespace }}</div>
</div>
</a>
</li>
</template>
<script>
import _ from 'underscore';
import eventHub from '../event_hub';
export default {
data() {
return {
searchQuery: '',
};
},
watch: {
searchQuery() {
this.handleInput();
},
},
mounted() {
eventHub.$on('dropdownOpen', this.setFocus);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.setFocus);
},
methods: {
setFocus() {
this.$refs.search.focus();
},
emitSearchEvents() {
if (this.searchQuery) {
eventHub.$emit('searchProjects', this.searchQuery);
} else {
eventHub.$emit('searchCleared');
}
},
/**
* Callback function within _.debounce is intentionally
* kept as ES5 `function() {}` instead of ES6 `() => {}`
* as it otherwise messes up function context
* and component reference is no longer accessible via `this`
*/
// eslint-disable-next-line func-names
handleInput: _.debounce(function () {
this.emitSearchEvents();
}, 500),
},
};
</script>
<template>
<div
class="search-input-container d-none d-sm-block"
>
<input
ref="search"
v-model="searchQuery"
:placeholder="s__('ProjectsDropdown|Search your projects')"
type="search"
class="form-control"
/>
<i
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
>
</i>
</div>
</template>
export const FREQUENT_PROJECTS = {
MAX_COUNT: 20,
LIST_COUNT_DESKTOP: 5,
LIST_COUNT_MOBILE: 3,
ELIGIBLE_FREQUENCY: 3,
};
export const HOUR_IN_MS = 3600000;
export const STORAGE_KEY = 'frequent-projects';
import $ from 'jquery';
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import eventHub from './event_hub';
import ProjectsService from './service/projects_service';
import ProjectsStore from './store/projects_store';
import projectsDropdownApp from './components/app.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('js-projects-dropdown');
const navEl = document.getElementById('nav-projects-dropdown');
// Don't do anything if element doesn't exist (No projects dropdown)
// This is for when the user accesses GitLab without logging in
if (!el || !navEl) {
return;
}
$(navEl).on('shown.bs.dropdown', () => {
eventHub.$emit('dropdownOpen');
});
// eslint-disable-next-line no-new
new Vue({
el,
components: {
projectsDropdownApp,
},
data() {
const { dataset } = this.$options.el;
const store = new ProjectsStore();
const service = new ProjectsService(dataset.userName);
const project = {
id: Number(dataset.projectId),
name: dataset.projectName,
namespace: dataset.projectNamespace,
webUrl: dataset.projectWebUrl,
avatarUrl: dataset.projectAvatarUrl || null,
lastAccessedOn: Date.now(),
};
return {
store,
service,
state: store.state,
currentUserName: dataset.userName,
currentProject: project,
};
},
render(createElement) {
return createElement('projects-dropdown-app', {
props: {
currentUserName: this.currentUserName,
currentProject: this.currentProject,
store: this.store,
service: this.service,
},
});
},
});
});
import _ from 'underscore';
import Vue from 'vue';
import VueResource from 'vue-resource';
import bp from '../../breakpoints';
import Api from '../../api';
import AccessorUtilities from '../../lib/utils/accessor';
import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants';
Vue.use(VueResource);
export default class ProjectsService {
constructor(currentUserName) {
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.currentUserName = currentUserName;
this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`;
this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath));
}
getSearchedProjects(searchQuery) {
return this.projectsPath.get({
simple: true,
per_page: 20,
membership: !!gon.current_user_id,
order_by: 'last_activity_at',
search: searchQuery,
});
}
getFrequentProjects() {
if (this.isLocalStorageAvailable) {
return this.getTopFrequentProjects();
}
return null;
}
logProjectAccess(project) {
let matchFound = false;
let storedFrequentProjects;
if (this.isLocalStorageAvailable) {
const storedRawProjects = localStorage.getItem(this.storageKey);
// Check if there's any frequent projects list set
if (!storedRawProjects) {
// No frequent projects list set, set one up.
storedFrequentProjects = [];
storedFrequentProjects.push({ ...project, frequency: 1 });
} else {
// Check if project is already present in frequents list
// When found, update metadata of it.
storedFrequentProjects = JSON.parse(storedRawProjects).map(projectItem => {
if (projectItem.id === project.id) {
matchFound = true;
const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS;
const updatedProject = {
...project,
frequency: projectItem.frequency,
lastAccessedOn: projectItem.lastAccessedOn,
};
// Check if duration since last access of this project
// is over an hour
if (diff > 1) {
return {
...updatedProject,
frequency: updatedProject.frequency + 1,
lastAccessedOn: Date.now(),
};
}
return {
...updatedProject,
};
}
return projectItem;
});
// Check whether currently logged project is present in frequents list
if (!matchFound) {
// We always keep size of frequents collection to 20 projects
// out of which only 5 projects with
// highest value of `frequency` and most recent `lastAccessedOn`
// are shown in projects dropdown
if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) {
storedFrequentProjects.shift(); // Remove an item from head of array
}
storedFrequentProjects.push({ ...project, frequency: 1 });
}
}
localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects));
}
}
getTopFrequentProjects() {
const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey));
let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP;
if (!storedFrequentProjects) {
return [];
}
if (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'xs') {
frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE;
}
const frequentProjects = storedFrequentProjects.filter(
project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY,
);
if (!frequentProjects || frequentProjects.length === 0) {
return [];
}
// Sort all frequent projects in decending order of frequency
// and then by lastAccessedOn with recent most first
frequentProjects.sort((projectA, projectB) => {
if (projectA.frequency < projectB.frequency) {
return 1;
} else if (projectA.frequency > projectB.frequency) {
return -1;
} else if (projectA.lastAccessedOn < projectB.lastAccessedOn) {
return 1;
} else if (projectA.lastAccessedOn > projectB.lastAccessedOn) {
return -1;
}
return 0;
});
return _.first(frequentProjects, frequentProjectsCount);
}
}
export default class ProjectsStore {
constructor() {
this.state = {};
this.state.frequentProjects = [];
this.state.searchedProjects = [];
}
setFrequentProjects(rawProjects) {
this.state.frequentProjects = rawProjects;
}
getFrequentProjects() {
return this.state.frequentProjects;
}
setSearchedProjects(rawProjects) {
this.state.searchedProjects = rawProjects.map(rawProject => ({
id: rawProject.id,
name: rawProject.name,
namespace: rawProject.name_with_namespace,
webUrl: rawProject.web_url,
avatarUrl: rawProject.avatar_url,
}));
}
getSearchedProjects() {
return this.state.searchedProjects;
}
clearSearchedProjects() {
this.state.searchedProjects = [];
}
}
...@@ -79,15 +79,11 @@ export default { ...@@ -79,15 +79,11 @@ export default {
</script> </script>
<template> <template>
<div class="mr-widget-heading deploy-heading"> <div class="mr-widget-heading deploy-heading append-bottom-default">
<div class="ci-widget media"> <div class="ci-widget media">
<div class="ci-status-icon ci-status-icon-success">
<span class="js-icon-link icon-link">
<status-icon status="success" />
</span>
</div>
<div class="media-body"> <div class="media-body">
<div class="deploy-body"> <div class="deploy-body">
<div class="deployment-info">
<template v-if="hasDeploymentMeta"> <template v-if="hasDeploymentMeta">
<span> <span>
Deployed to Deployed to
...@@ -101,23 +97,6 @@ export default { ...@@ -101,23 +97,6 @@ export default {
{{ deployment.name }} {{ deployment.name }}
</a> </a>
</template> </template>
<template v-if="hasExternalUrls">
<span>
on
</span>
<a
:href="deployment.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-url"
>
{{ deployment.external_url_formatted }}
<icon
:size="16"
name="external-link"
/>
</a>
</template>
<span <span
v-tooltip v-tooltip
v-if="hasDeploymentTime" v-if="hasDeploymentTime"
...@@ -126,20 +105,37 @@ export default { ...@@ -126,20 +105,37 @@ export default {
> >
{{ deployTimeago }} {{ deployTimeago }}
</span> </span>
<loading-button
v-if="deployment.stop_url"
:loading="isStopping"
container-class="btn btn-default btn-sm prepend-left-default"
label="Stop environment"
@click="stopEnvironment"
/>
</div>
<memory-usage <memory-usage
v-if="hasMetrics" v-if="hasMetrics"
:metrics-url="deployment.metrics_url" :metrics-url="deployment.metrics_url"
:metrics-monitoring-url="deployment.metrics_monitoring_url" :metrics-monitoring-url="deployment.metrics_monitoring_url"
/> />
</div> </div>
<div>
<a
v-if="hasExternalUrls"
:href="deployment.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-url btn btn-default btn-sm inline"
>
<span>
View app
<icon name="external-link" />
</span>
</a>
<loading-button
v-if="deployment.stop_url"
:loading="isStopping"
container-class="btn btn-default btn-sm inline prepend-left-4"
title="Stop environment"
@click="stopEnvironment"
>
<icon name="stop" />
</loading-button>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import { webIDEUrl } from '~/lib/utils/url_utility'; import { webIDEUrl } from '~/lib/utils/url_utility';
import icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default { export default {
...@@ -11,7 +11,7 @@ export default { ...@@ -11,7 +11,7 @@ export default {
tooltip, tooltip,
}, },
components: { components: {
icon, Icon,
clipboardButton, clipboardButton,
}, },
props: { props: {
...@@ -54,7 +54,11 @@ export default { ...@@ -54,7 +54,11 @@ export default {
}; };
</script> </script>
<template> <template>
<div class="mr-source-target"> <div class="mr-source-target append-bottom-default">
<div class="git-merge-icon-container append-right-default">
<icon name="git-merge" />
</div>
<div class="git-merge-container d-flex">
<div class="normal"> <div class="normal">
<strong> <strong>
{{ s__("mrWidget|Request to merge") }} {{ s__("mrWidget|Request to merge") }}
...@@ -91,27 +95,32 @@ export default { ...@@ -91,27 +95,32 @@ export default {
</a> </a>
</span> </span>
</strong> </strong>
<span <div
v-if="shouldShowCommitsBehindText" v-if="shouldShowCommitsBehindText"
class="diverged-commits-count" class="diverged-commits-count"
> >
(<a :href="mr.targetBranchPath">{{ commitsText }}</a>) <span class="monospace">{{ mr.sourceBranch }}</span>
</span> is {{ commitsText }}
<span class="monospace">{{ mr.targetBranch }}</span>
</div>
</div> </div>
<div v-if="mr.isOpen"> <div
v-if="mr.isOpen"
class="branch-actions"
>
<a <a
v-if="!mr.sourceBranchRemoved" v-if="!mr.sourceBranchRemoved"
:href="webIdePath" :href="webIdePath"
class="btn btn-sm btn-default inline js-web-ide" class="btn btn-default inline js-web-ide d-none d-md-inline-block"
> >
{{ s__("mrWidget|Web IDE") }} {{ s__("mrWidget|Open in Web IDE") }}
</a> </a>
<button <button
:disabled="mr.sourceBranchRemoved" :disabled="mr.sourceBranchRemoved"
data-target="#modal_merge_info" data-target="#modal_merge_info"
data-toggle="modal" data-toggle="modal"
class="btn btn-sm btn-default inline js-check-out-branch" class="btn btn-default inline js-check-out-branch"
type="button" type="button"
> >
{{ s__("mrWidget|Check out branch") }} {{ s__("mrWidget|Check out branch") }}
...@@ -119,7 +128,7 @@ export default { ...@@ -119,7 +128,7 @@ export default {
<span class="dropdown prepend-left-10"> <span class="dropdown prepend-left-10">
<button <button
type="button" type="button"
class="btn btn-sm inline dropdown-toggle" class="btn inline dropdown-toggle"
data-toggle="dropdown" data-toggle="dropdown"
aria-label="Download as" aria-label="Download as"
aria-haspopup="true" aria-haspopup="true"
...@@ -154,4 +163,5 @@ export default { ...@@ -154,4 +163,5 @@ export default {
</span> </span>
</div> </div>
</div> </div>
</div>
</template> </template>
...@@ -26,6 +26,10 @@ export default { ...@@ -26,6 +26,10 @@ export default {
type: String, type: String,
required: false, required: false,
}, },
sourceBranchLink: {
type: String,
required: false,
},
}, },
computed: { computed: {
hasPipeline() { hasPipeline() {
...@@ -54,12 +58,18 @@ export default { ...@@ -54,12 +58,18 @@ export default {
<template> <template>
<div <div
v-if="hasPipeline || hasCIError" v-if="hasPipeline || hasCIError"
class="mr-widget-heading" class="mr-widget-heading append-bottom-default"
> >
<div class="ci-widget media"> <div class="ci-widget media">
<template v-if="hasCIError"> <template v-if="hasCIError">
<div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10"> <div
<icon name="status_failed" /> class="add-border ci-status-icon ci-status-icon-failed ci-error
js-ci-error append-right-default"
>
<icon
:size="32"
name="status_failed_borderless"
/>
</div> </div>
<div class="media-body"> <div class="media-body">
Could not connect to the CI server. Please check your settings and try again Could not connect to the CI server. Please check your settings and try again
...@@ -68,32 +78,51 @@ export default { ...@@ -68,32 +78,51 @@ export default {
<template v-else-if="hasPipeline"> <template v-else-if="hasPipeline">
<a <a
:href="status.details_path" :href="status.details_path"
class="append-right-10" class="align-self-start append-right-default"
> >
<ci-icon :status="status" /> <ci-icon
:status="status"
:size="32"
:borderless="true"
class="add-border"
/>
</a> </a>
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">
<div class="media-body"> <div class="media-body">
<div class="font-weight-bold">
Pipeline Pipeline
<a <a
:href="pipeline.path" :href="pipeline.path"
class="pipeline-id" class="pipeline-id font-weight-normal pipeline-number"
> >#{{ pipeline.id }}</a>
#{{ pipeline.id }}
</a>
{{ pipeline.details.status.label }} {{ pipeline.details.status.label }}
<template v-if="hasCommitInfo"> <template v-if="hasCommitInfo">
for for
<a <a
:href="pipeline.commit.commit_path" :href="pipeline.commit.commit_path"
class="commit-sha js-commit-link" class="commit-sha js-commit-link font-weight-normal"
> >
{{ pipeline.commit.short_id }}</a>. {{ pipeline.commit.short_id }}</a>
on
<span
class="label-branch"
v-html="sourceBranchLink"
>
</span>
</template> </template>
</div>
<div
v-if="pipeline.coverage"
class="coverage"
>
Coverage {{ pipeline.coverage }}%
</div>
</div>
</div>
<div>
<span class="mr-widget-pipeline-graph"> <span class="mr-widget-pipeline-graph">
<span <span
v-if="hasStages" v-if="hasStages"
...@@ -102,16 +131,13 @@ export default { ...@@ -102,16 +131,13 @@ export default {
<div <div
v-for="(stage, i) in pipeline.details.stages" v-for="(stage, i) in pipeline.details.stages"
:key="i" :key="i"
class="stage-container dropdown js-mini-pipeline-graph" class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages"
> >
<pipeline-stage :stage="stage" /> <pipeline-stage :stage="stage" />
</div> </div>
</span> </span>
</span> </span>
</div>
<template v-if="pipeline.coverage">
Coverage {{ pipeline.coverage }}%
</template>
</div> </div>
</template> </template>
</div> </div>
......
...@@ -43,6 +43,7 @@ ...@@ -43,6 +43,7 @@
<ci-icon <ci-icon
v-else v-else
:status="statusObj" :status="statusObj"
:size="24"
/> />
<button <button
......
...@@ -252,12 +252,14 @@ export default { ...@@ -252,12 +252,14 @@ export default {
:pipeline="mr.pipeline" :pipeline="mr.pipeline"
:ci-status="mr.ciStatus" :ci-status="mr.ciStatus"
:has-ci="mr.hasCI" :has-ci="mr.hasCI"
:source-branch-link="mr.sourceBranchLink"
/> />
<deployment <deployment
v-for="deployment in mr.deployments" v-for="deployment in mr.deployments"
:key="deployment.id" :key="deployment.id"
:deployment="deployment" :deployment="deployment"
/> />
<div class="mr-section-container">
<div class="mr-widget-section"> <div class="mr-widget-section">
<component <component
:is="componentName" :is="componentName"
...@@ -289,4 +291,5 @@ export default { ...@@ -289,4 +291,5 @@ export default {
<mr-widget-merge-help /> <mr-widget-merge-help />
</div> </div>
</div> </div>
</div>
</template> </template>
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import { s__ } from '~/locale';
import Flash from '../../../flash'; import Flash from '../../../flash';
import GLForm from '../../../gl_form'; import GLForm from '../../../gl_form';
import markdownHeader from './header.vue'; import markdownHeader from './header.vue';
...@@ -22,6 +23,11 @@ ...@@ -22,6 +23,11 @@
type: String, type: String,
required: true, required: true,
}, },
markdownVersion: {
type: Number,
required: false,
default: 0,
},
addSpacingClasses: { addSpacingClasses: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -92,10 +98,11 @@ ...@@ -92,10 +98,11 @@
if (text) { if (text) {
this.markdownPreviewLoading = true; this.markdownPreviewLoading = true;
this.$http.post(this.markdownPreviewPath, { text }) this.$http
.post(this.versionedPreviewPath(), { text })
.then(resp => resp.json()) .then(resp => resp.json())
.then(data => this.renderMarkdown(data)) .then(data => this.renderMarkdown(data))
.catch(() => new Flash('Error loading markdown preview')); .catch(() => new Flash(s__('Error loading markdown preview')));
} else { } else {
this.renderMarkdown(); this.renderMarkdown();
} }
...@@ -119,6 +126,13 @@ ...@@ -119,6 +126,13 @@
$(this.$refs['markdown-preview']).renderGFM(); $(this.$refs['markdown-preview']).renderGFM();
}); });
}, },
versionedPreviewPath() {
const { markdownPreviewPath, markdownVersion } = this;
return `${markdownPreviewPath}${
markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'
}markdown_version=${markdownVersion}`;
},
}, },
}; };
</script> </script>
......
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
width: 100%; width: 100%;
} }
&.projects-dropdown-menu { &.frequent-items-dropdown-menu {
padding: 0; padding: 0;
overflow-y: initial; overflow-y: initial;
max-height: initial; max-height: initial;
...@@ -790,6 +790,7 @@ ...@@ -790,6 +790,7 @@
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
.navbar-gitlab { .navbar-gitlab {
li.header-projects, li.header-projects,
li.header-groups,
li.header-more, li.header-more,
li.header-new, li.header-new,
li.header-user { li.header-user {
...@@ -813,18 +814,18 @@ ...@@ -813,18 +814,18 @@
} }
} }
header.header-content .dropdown-menu.projects-dropdown-menu { header.header-content .dropdown-menu.frequent-items-dropdown-menu {
padding: 0; padding: 0;
} }
.projects-dropdown-container { .frequent-items-dropdown-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 500px; width: 500px;
height: 334px; height: 334px;
.project-dropdown-sidebar, .frequent-items-dropdown-sidebar,
.project-dropdown-content { .frequent-items-dropdown-content {
padding: 8px 0; padding: 8px 0;
} }
...@@ -832,12 +833,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -832,12 +833,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
color: $almost-black; color: $almost-black;
} }
.project-dropdown-sidebar { .frequent-items-dropdown-sidebar {
width: 30%; width: 30%;
border-right: 1px solid $border-color; border-right: 1px solid $border-color;
} }
.project-dropdown-content { .frequent-items-dropdown-content {
position: relative; position: relative;
width: 70%; width: 70%;
} }
...@@ -848,33 +849,35 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -848,33 +849,35 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
height: auto; height: auto;
flex: 1; flex: 1;
.project-dropdown-sidebar, .frequent-items-dropdown-sidebar,
.project-dropdown-content { .frequent-items-dropdown-content {
width: 100%; width: 100%;
} }
.project-dropdown-sidebar { .frequent-items-dropdown-sidebar {
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
border-right: 0; border-right: 0;
} }
} }
.projects-list-frequent-container, .section-header,
.projects-list-search-container { .frequent-items-list-container li.section-empty {
padding: 0 $gl-padding;
color: $gl-text-color-secondary;
font-size: $gl-font-size;
}
.frequent-items-list-container {
padding: 8px 0; padding: 8px 0;
overflow-y: auto; overflow-y: auto;
li.section-empty.section-failure { li.section-empty.section-failure {
color: $callout-danger-color; color: $callout-danger-color;
} }
}
.section-header, .frequent-items-list-item-container a {
.projects-list-frequent-container li.section-empty, display: flex;
.projects-list-search-container li.section-empty { }
padding: 0 15px;
color: $gl-text-color-secondary;
font-size: $gl-font-size;
} }
.search-input-container { .search-input-container {
...@@ -894,12 +897,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -894,12 +897,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
margin-top: 8px; margin-top: 8px;
} }
.projects-list-search-container { .frequent-items-search-container {
height: 284px; height: 284px;
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
.projects-list-frequent-container { .frequent-items-list-container {
width: auto; width: auto;
height: auto; height: auto;
padding-bottom: 0; padding-bottom: 0;
...@@ -907,32 +910,38 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -907,32 +910,38 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
} }
} }
.projects-list-item-container { .frequent-items-list-item-container {
.project-item-avatar-container .project-item-metadata-container { .frequent-items-item-avatar-container,
.frequent-items-item-metadata-container {
float: left; float: left;
} }
.project-title, .frequent-items-item-metadata-container {
.project-namespace { display: flex;
flex-direction: column;
justify-content: center;
}
.frequent-items-item-title,
.frequent-items-item-namespace {
max-width: 250px; max-width: 250px;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
&:hover { &:hover {
.project-item-avatar-container .avatar { .frequent-items-item-avatar-container .avatar {
border-color: $md-area-border; border-color: $md-area-border;
} }
} }
.project-title { .frequent-items-item-title {
font-size: $gl-font-size; font-size: $gl-font-size;
font-weight: 400; font-weight: 400;
line-height: 16px; line-height: 16px;
} }
.project-namespace { .frequent-items-item-namespace {
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
line-height: 12px; line-height: 12px;
...@@ -940,7 +949,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -940,7 +949,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
.project-item-metadata-container { .frequent-items-item-metadata-container {
float: none; float: none;
} }
} }
......
...@@ -255,3 +255,8 @@ label { ...@@ -255,3 +255,8 @@ label {
color: $theme-gray-600; color: $theme-gray-600;
} }
} }
.input-lg {
max-width: 320px;
width: 100%;
}
...@@ -29,16 +29,22 @@ ...@@ -29,16 +29,22 @@
.navbar-sub-nav, .navbar-sub-nav,
.navbar-nav { .navbar-nav {
> li { > li {
> a:hover, > a,
> a:focus { > button {
&:hover,
&:focus {
background-color: rgba($search-and-nav-links, 0.2); background-color: rgba($search-and-nav-links, 0.2);
} }
}
&.active > a, &.active,
&.dropdown.show > a { &.dropdown.show {
> a,
> button {
color: $nav-svg-color; color: $nav-svg-color;
background-color: $color-alternate; background-color: $color-alternate;
} }
}
&.line-separator { &.line-separator {
border-left: 1px solid rgba($search-and-nav-links, 0.2); border-left: 1px solid rgba($search-and-nav-links, 0.2);
...@@ -147,7 +153,6 @@ ...@@ -147,7 +153,6 @@
} }
} }
// Sidebar // Sidebar
.nav-sidebar li.active { .nav-sidebar li.active {
box-shadow: inset 4px 0 0 $border-and-box-shadow; box-shadow: inset 4px 0 0 $border-and-box-shadow;
......
...@@ -269,14 +269,8 @@ ...@@ -269,14 +269,8 @@
.navbar-sub-nav, .navbar-sub-nav,
.navbar-nav { .navbar-nav {
> li { > li {
> a:hover, > a,
> a:focus { > button {
text-decoration: none;
outline: 0;
color: $white-light;
}
> a {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -288,6 +282,18 @@ ...@@ -288,6 +282,18 @@
border-radius: $border-radius-default; border-radius: $border-radius-default;
height: 32px; height: 32px;
font-weight: $gl-font-weight-bold; font-weight: $gl-font-weight-bold;
&:hover,
&:focus {
text-decoration: none;
outline: 0;
color: $white-light;
}
}
> button {
background: transparent;
border: 0;
} }
&.line-separator { &.line-separator {
...@@ -311,7 +317,7 @@ ...@@ -311,7 +317,7 @@
font-size: 10px; font-size: 10px;
} }
.project-item-select-holder { .frequent-items-item-select-holder {
display: inline; display: inline;
} }
......
...@@ -3,12 +3,20 @@ ...@@ -3,12 +3,20 @@
svg { svg {
fill: $green-500; fill: $green-500;
} }
&.add-border {
@include borderless-status-icon($green-500);
}
} }
.ci-status-icon-failed { .ci-status-icon-failed {
svg { svg {
fill: $gl-danger; fill: $gl-danger;
} }
&.add-border {
@include borderless-status-icon($red-500);
}
} }
.ci-status-icon-pending, .ci-status-icon-pending,
...@@ -17,12 +25,20 @@ ...@@ -17,12 +25,20 @@
svg { svg {
fill: $orange-500; fill: $orange-500;
} }
&.add-border {
@include borderless-status-icon($orange-500);
}
} }
.ci-status-icon-running { .ci-status-icon-running {
svg { svg {
fill: $blue-400; fill: $blue-400;
} }
&.add-border {
@include borderless-status-icon($blue-400);
}
} }
.ci-status-icon-canceled, .ci-status-icon-canceled,
...@@ -30,6 +46,10 @@ ...@@ -30,6 +46,10 @@
svg { svg {
fill: $gl-text-color; fill: $gl-text-color;
} }
&.add-border {
@include borderless-status-icon($gl-text-color);
}
} }
.ci-status-icon-created, .ci-status-icon-created,
...@@ -38,6 +58,10 @@ ...@@ -38,6 +58,10 @@
svg { svg {
fill: $gray-darkest; fill: $gray-darkest;
} }
&.add-border {
@include borderless-status-icon($gray-darkest);
}
} }
.ci-status-icon-manual { .ci-status-icon-manual {
......
...@@ -232,3 +232,10 @@ ...@@ -232,3 +232,10 @@
word-break: break-word; word-break: break-word;
max-width: 100%; max-width: 100%;
} }
@mixin borderless-status-icon($color) {
svg {
border: 1px solid $color;
border-radius: 50%;
}
}
...@@ -350,7 +350,8 @@ code { ...@@ -350,7 +350,8 @@ code {
} }
.commit-sha, .commit-sha,
.ref-name { .ref-name,
.pipeline-number {
@extend .monospace; @extend .monospace;
font-size: 95%; font-size: 95%;
} }
......
...@@ -743,6 +743,7 @@ Pipeline Graph ...@@ -743,6 +743,7 @@ Pipeline Graph
*/ */
$stage-hover-bg: $gray-darker; $stage-hover-bg: $gray-darker;
$ci-action-icon-size: 22px; $ci-action-icon-size: 22px;
$ci-action-icon-size-lg: 24px;
$pipeline-dropdown-line-height: 20px; $pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px; $pipeline-dropdown-status-icon-size: 18px;
$ci-action-dropdown-button-size: 24px; $ci-action-dropdown-button-size: 24px;
......
...@@ -80,7 +80,6 @@ ...@@ -80,7 +80,6 @@
overflow-x: scroll; overflow-x: scroll;
white-space: nowrap; white-space: nowrap;
min-height: 200px; min-height: 200px;
display: flex;
@include media-breakpoint-only(sm) { @include media-breakpoint-only(sm) {
height: calc(100vh - #{$issue-board-list-difference-sm}); height: calc(100vh - #{$issue-board-list-difference-sm});
...@@ -111,15 +110,17 @@ ...@@ -111,15 +110,17 @@
.board { .board {
display: inline-block; display: inline-block;
flex: 1; width: calc(85vw - 15px);
min-width: 300px;
max-width: 400px;
height: 100%; height: 100%;
padding-right: ($gl-padding / 2); padding-right: ($gl-padding / 2);
padding-left: ($gl-padding / 2); padding-left: ($gl-padding / 2);
white-space: normal; white-space: normal;
vertical-align: top; vertical-align: top;
@include media-breakpoint-up(sm) {
width: 400px;
}
&.is-expandable { &.is-expandable {
.board-header { .board-header {
cursor: pointer; cursor: pointer;
...@@ -127,8 +128,6 @@ ...@@ -127,8 +128,6 @@
} }
&.is-collapsed { &.is-collapsed {
flex: none;
min-width: 0;
width: 50px; width: 50px;
.board-header { .board-header {
......
...@@ -15,16 +15,38 @@ ...@@ -15,16 +15,38 @@
} }
} }
.mr-widget-heading {
position: relative;
border: 1px solid $border-color;
border-radius: 4px;
&:not(.deploy-heading)::before {
content: '';
border-left: 1px solid $theme-gray-200;
position: absolute;
left: 32px;
top: -17px;
height: 16px;
}
}
.mr-section-container {
border: 1px solid $border-color;
border-radius: $border-radius-default;
border-top: 0;
}
.mr-widget-heading,
.mr-widget-section,
.mr-widget-footer {
padding: $gl-padding;
}
.mr-state-widget { .mr-state-widget {
color: $gl-text-color; color: $gl-text-color;
border: 1px solid $border-color;
border-radius: 2px;
line-height: 28px;
.mr-widget-heading,
.mr-widget-section, .mr-widget-section,
.mr-widget-footer { .mr-widget-footer {
padding: $gl-padding;
border-top: solid 1px $border-color; border-top: solid 1px $border-color;
} }
...@@ -124,10 +146,17 @@ ...@@ -124,10 +146,17 @@
.ci-widget { .ci-widget {
color: $gl-text-color; color: $gl-text-color;
display: flex; display: flex;
align-items: center;
justify-content: space-between;
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
flex-wrap: wrap; flex-wrap: wrap;
} }
.ci-widget-content {
display: flex;
align-items: center;
}
} }
.mr-widget-icon { .mr-widget-icon {
...@@ -136,8 +165,6 @@ ...@@ -136,8 +165,6 @@
} }
.ci-status-icon svg { .ci-status-icon svg {
width: $status-icon-size;
height: $status-icon-size;
margin: 3px 0; margin: 3px 0;
position: relative; position: relative;
overflow: visible; overflow: visible;
...@@ -145,8 +172,6 @@ ...@@ -145,8 +172,6 @@
} }
.mr-widget-pipeline-graph { .mr-widget-pipeline-graph {
padding: 0 4px;
.dropdown-menu { .dropdown-menu {
z-index: 300; z-index: 300;
} }
...@@ -157,7 +182,7 @@ ...@@ -157,7 +182,7 @@
} }
.normal { .normal {
line-height: 28px; flex: 1;
} }
.capitalize { .capitalize {
...@@ -168,7 +193,7 @@ ...@@ -168,7 +193,7 @@
@extend .ref-name; @extend .ref-name;
color: $gl-text-color; color: $gl-text-color;
font-weight: $gl-font-weight-bold; font-weight: normal;
overflow: hidden; overflow: hidden;
word-break: break-all; word-break: break-all;
...@@ -192,6 +217,8 @@ ...@@ -192,6 +217,8 @@
} }
.mr-widget-body { .mr-widget-body {
line-height: 28px;
@include clearfix; @include clearfix;
&.media > *:first-child { &.media > *:first-child {
...@@ -474,18 +501,66 @@ ...@@ -474,18 +501,66 @@
} }
} }
.merge-request-details .content-block {
border-bottom: 0;
}
.mr-source-target { .mr-source-target {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; border-radius: $border-radius-default;
padding: $gl-padding;
border: 1px solid $border-color;
min-height: 69px;
@include media-breakpoint-up(md) {
align-items: center; align-items: center;
background-color: $gray-light; }
border-radius: $border-radius-default $border-radius-default 0 0;
padding: $gl-padding / 2 $gl-padding;
.dropdown-toggle .fa { .dropdown-toggle .fa {
color: $gl-text-color; color: $gl-text-color;
} }
.git-merge-icon-container {
border: 1px solid $theme-gray-400;
border-radius: 50%;
height: 32px;
width: 32px;
color: $theme-gray-700;
line-height: 28px;
.ic-git-merge {
vertical-align: middle;
width: 31px;
}
}
.git-merge-container {
justify-content: space-between;
flex: 1;
flex-direction: row;
align-items: center;
@include media-breakpoint-down(md) {
flex-direction: column;
align-items: flex-start;
.branch-actions {
margin-top: 16px;
}
}
@include media-breakpoint-up(lg) {
.branch-actions {
align-self: center;
}
}
}
.diverged-commits-count {
color: $gl-text-color-secondary;
font-size: 12px;
}
} }
.card-new-merge-request { .card-new-merge-request {
...@@ -720,13 +795,25 @@ ...@@ -720,13 +795,25 @@
} }
.deploy-heading { .deploy-heading {
margin-top: -19px;
border-top-left-radius: 0;
border-top-right-radius: 0;
background-color: $gray-light;
@include media-breakpoint-up(md) {
padding: $gl-padding-8 $gl-padding;
}
.media-body { .media-body {
min-width: 0; min-width: 0;
font-size: 12px;
margin-left: 48px;
} }
} }
.deploy-body { .deploy-body {
display: flex; display: flex;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
@include media-breakpoint-up(xs) { @include media-breakpoint-up(xs) {
...@@ -734,6 +821,15 @@ ...@@ -734,6 +821,15 @@
white-space: nowrap; white-space: nowrap;
} }
@include media-breakpoint-down(md) {
flex-direction: column;
align-items: flex-start;
.deployment-info {
margin-bottom: $gl-padding;
}
}
> *:not(:last-child) { > *:not(:last-child) {
margin-right: .3em; margin-right: .3em;
} }
...@@ -741,19 +837,23 @@ ...@@ -741,19 +837,23 @@
svg { svg {
vertical-align: text-top; vertical-align: text-top;
} }
}
.deploy-link { .deployment-info {
flex: 1;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
min-width: 100px; min-width: 100px;
max-width: 150px;
@include media-breakpoint-up(xs) { @include media-breakpoint-up(xs) {
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
} }
}
.btn svg {
fill: $theme-gray-700;
}
} }
// Hack alert: we've rewritten `btn` class in a way that // Hack alert: we've rewritten `btn` class in a way that
...@@ -772,3 +872,33 @@ ...@@ -772,3 +872,33 @@
} }
} }
} }
.ci-widget-container {
justify-content: space-between;
flex: 1;
flex-direction: row;
@include media-breakpoint-down(md) {
flex-direction: column;
.stage-cell .stage-container {
margin-top: 16px;
}
.dropdown .mini-pipeline-graph-dropdown-menu.dropdown-menu {
transform: initial;
}
}
.coverage {
font-size: 12px;
color: $theme-gray-700;
line-height: initial;
}
.mini-pipeline-graph-dropdown-toggle,
.stage-cell .mini-pipeline-graph-dropdown-toggle svg {
height: $ci-action-icon-size-lg;
width: $ci-action-icon-size-lg;
}
}
...@@ -301,6 +301,21 @@ ...@@ -301,6 +301,21 @@
border-bottom: 2px solid $border-color; border-bottom: 2px solid $border-color;
} }
} }
//delete when all pipelines are updated to new size
&.mr-widget-pipeline-stages {
+ .stage-container {
margin-left: 4px;
}
&:not(:last-child) {
&::after {
width: 4px;
right: -4px;
top: 11px;
}
}
}
} }
} }
......
...@@ -1329,3 +1329,14 @@ ...@@ -1329,3 +1329,14 @@
line-height: 16px; line-height: 16px;
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
} }
.ide-merge-request-info {
.detail-page-header {
line-height: initial;
min-height: 38px;
}
.issuable-details {
overflow: auto;
}
}
module GroupTree module GroupTree
# rubocop:disable Gitlab/ModuleWithInstanceVariables # rubocop:disable Gitlab/ModuleWithInstanceVariables
def render_group_tree(groups) def render_group_tree(groups)
@groups = if params[:filter].present? groups = groups.sort_by_attribute(@sort = params[:sort])
# We find the ancestors by ID of the search results here.
# Otherwise the ancestors would also have filters applied, groups = if params[:filter].present?
# which would cause them not to be preloaded. filtered_groups_with_ancestors(groups)
group_ids = groups.search(params[:filter]).select(:id)
Gitlab::GroupHierarchy.new(Group.where(id: group_ids))
.base_and_ancestors
else else
# Only show root groups if no parent-id is given # If `params[:parent_id]` is `nil`, we will only show root-groups
groups.where(parent_id: params[:parent_id]) groups.where(parent_id: params[:parent_id]).page(params[:page])
end end
@groups = @groups.with_selects_for_list(archived: params[:archived]) @groups = groups.with_selects_for_list(archived: params[:archived])
.sort_by_attribute(@sort = params[:sort])
.page(params[:page])
respond_to do |format| respond_to do |format|
format.html format.html
...@@ -28,4 +23,21 @@ module GroupTree ...@@ -28,4 +23,21 @@ module GroupTree
end end
# rubocop:enable Gitlab/ModuleWithInstanceVariables # rubocop:enable Gitlab/ModuleWithInstanceVariables
end end
def filtered_groups_with_ancestors(groups)
filtered_groups = groups.search(params[:filter]).page(params[:page])
if Group.supports_nested_groups?
# We find the ancestors by ID of the search results here.
# Otherwise the ancestors would also have filters applied,
# which would cause them not to be preloaded.
#
# Pagination needs to be applied before loading the ancestors to
# make sure ancestors are not cut off by pagination.
Gitlab::GroupHierarchy.new(Group.where(id: filtered_groups.select(:id)))
.base_and_ancestors
else
filtered_groups
end
end
end end
...@@ -14,6 +14,8 @@ module PreviewMarkdown ...@@ -14,6 +14,8 @@ module PreviewMarkdown
else {} else {}
end end
markdown_params[:markdown_engine] = result[:markdown_engine]
render json: { render json: {
body: view_context.markdown(result[:text], markdown_params), body: view_context.markdown(result[:text], markdown_params),
references: { references: {
......
...@@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController ...@@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
end end
def labels def labels
render json: @autocomplete_service.labels(target) render json: @autocomplete_service.labels_as_hash(target)
end end
def milestones def milestones
......
...@@ -2,6 +2,7 @@ class ProjectsController < Projects::ApplicationController ...@@ -2,6 +2,7 @@ class ProjectsController < Projects::ApplicationController
include IssuableCollections include IssuableCollections
include ExtractsPath include ExtractsPath
include PreviewMarkdown include PreviewMarkdown
include SendFileUpload
before_action :whitelist_query_limiting, only: [:create] before_action :whitelist_query_limiting, only: [:create]
before_action :authenticate_user!, except: [:index, :show, :activity, :refs] before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
...@@ -188,9 +189,9 @@ class ProjectsController < Projects::ApplicationController ...@@ -188,9 +189,9 @@ class ProjectsController < Projects::ApplicationController
end end
def download_export def download_export
export_project_path = @project.export_project_path if export_project_object_storage?
send_upload(@project.import_export_upload.export_file)
if export_project_path elsif export_project_path
send_file export_project_path, disposition: 'attachment' send_file export_project_path, disposition: 'attachment'
else else
redirect_to( redirect_to(
...@@ -265,8 +266,6 @@ class ProjectsController < Projects::ApplicationController ...@@ -265,8 +266,6 @@ class ProjectsController < Projects::ApplicationController
render json: options.to_json render json: options.to_json
end end
private
# Render project landing depending of which features are available # Render project landing depending of which features are available
# So if page is not availble in the list it renders the next page # So if page is not availble in the list it renders the next page
# #
...@@ -424,4 +423,12 @@ class ProjectsController < Projects::ApplicationController ...@@ -424,4 +423,12 @@ class ProjectsController < Projects::ApplicationController
def whitelist_query_limiting def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440') Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440')
end end
def export_project_path
@export_project_path ||= @project.export_project_path
end
def export_project_object_storage?
@project.export_project_object_exists?
end
end end
...@@ -128,8 +128,10 @@ module GroupsHelper ...@@ -128,8 +128,10 @@ module GroupsHelper
def get_group_sidebar_links def get_group_sidebar_links
links = [:overview, :group_members] links = [:overview, :group_members]
if can?(current_user, :read_cross_project) resources = [:activity, :issues, :boards, :labels, :milestones,
links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests] :merge_requests]
links += resources.select do |resource|
can?(current_user, "read_group_#{resource}".to_sym, @group)
end end
if can?(current_user, :admin_group, @group) if can?(current_user, :admin_group, @group)
......
...@@ -249,6 +249,7 @@ module IssuablesHelper ...@@ -249,6 +249,7 @@ module IssuablesHelper
issuableRef: issuable.to_reference, issuableRef: issuable.to_reference,
markdownPreviewPath: preview_markdown_path(parent), markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'), markdownDocsPath: help_page_path('user/markdown'),
markdownVersion: issuable.cached_markdown_version,
issuableTemplates: issuable_templates(issuable), issuableTemplates: issuable_templates(issuable),
initialTitleHtml: markdown_field(issuable, :title), initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title, initialTitleText: issuable.title,
......
...@@ -107,6 +107,7 @@ module MarkupHelper ...@@ -107,6 +107,7 @@ module MarkupHelper
def markup(file_name, text, context = {}) def markup(file_name, text, context = {})
context[:project] ||= @project context[:project] ||= @project
context[:markdown_engine] ||= :redcarpet
html = context.delete(:rendered) || markup_unsafe(file_name, text, context) html = context.delete(:rendered) || markup_unsafe(file_name, text, context)
prepare_for_rendering(html, context) prepare_for_rendering(html, context)
end end
...@@ -120,7 +121,8 @@ module MarkupHelper ...@@ -120,7 +121,8 @@ module MarkupHelper
project: @project, project: @project,
project_wiki: @project_wiki, project_wiki: @project_wiki,
page_slug: wiki_page.slug, page_slug: wiki_page.slug,
issuable_state_filter_enabled: true issuable_state_filter_enabled: true,
markdown_engine: :redcarpet
} }
html = html =
......
...@@ -169,6 +169,7 @@ module NotesHelper ...@@ -169,6 +169,7 @@ module NotesHelper
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
markdownDocsPath: help_page_path('user/markdown'), markdownDocsPath: help_page_path('user/markdown'),
markdownVersion: issuable.cached_markdown_version,
quickActionsDocsPath: help_page_path('user/project/quick_actions'), quickActionsDocsPath: help_page_path('user/project/quick_actions'),
closePath: close_issuable_path(issuable), closePath: close_issuable_path(issuable),
reopenPath: reopen_issuable_path(issuable), reopenPath: reopen_issuable_path(issuable),
......
...@@ -153,7 +153,7 @@ class NotifyPreview < ActionMailer::Preview ...@@ -153,7 +153,7 @@ class NotifyPreview < ActionMailer::Preview
cleanup do cleanup do
note = yield note = yield
Notify.public_send(method, user.id, note) Notify.public_send(method, user.id, note) # rubocop:disable GitlabSecurity/PublicSend
end end
end end
......
module Ci module Ci
class BuildTraceChunk < ActiveRecord::Base class BuildTraceChunk < ActiveRecord::Base
include FastDestroyAll include FastDestroyAll
include ::Gitlab::ExclusiveLeaseHelpers
extend Gitlab::Ci::Model extend Gitlab::Ci::Model
belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
default_value_for :data_store, :redis default_value_for :data_store, :redis
WriteError = Class.new(StandardError)
CHUNK_SIZE = 128.kilobytes CHUNK_SIZE = 128.kilobytes
CHUNK_REDIS_TTL = 1.week
WRITE_LOCK_RETRY = 10 WRITE_LOCK_RETRY = 10
WRITE_LOCK_SLEEP = 0.01.seconds WRITE_LOCK_SLEEP = 0.01.seconds
WRITE_LOCK_TTL = 1.minute WRITE_LOCK_TTL = 1.minute
# Note: The ordering of this enum is related to the precedence of persist store.
# The bottom item takes the higest precedence, and the top item takes the lowest precedence.
enum data_store: { enum data_store: {
redis: 1, redis: 1,
db: 2 database: 2,
fog: 3
} }
class << self class << self
def redis_data_key(build_id, chunk_index) def all_stores
"gitlab:ci:trace:#{build_id}:chunks:#{chunk_index}" @all_stores ||= self.data_stores.keys
end end
def redis_data_keys def persistable_store
redis.pluck(:build_id, :chunk_index).map do |data| # get first available store from the back of the list
redis_data_key(data.first, data.second) all_stores.reverse.find { |store| get_store_class(store).available? }
end
end end
def redis_delete_data(keys) def get_store_class(store)
return if keys.empty? @stores ||= {}
@stores[store] ||= "Ci::BuildTraceChunks::#{store.capitalize}".constantize.new
Gitlab::Redis::SharedState.with do |redis|
redis.del(keys)
end
end end
## ##
# FastDestroyAll concerns # FastDestroyAll concerns
def begin_fast_destroy def begin_fast_destroy
redis_data_keys all_stores.each_with_object({}) do |store, result|
relation = public_send(store) # rubocop:disable GitlabSecurity/PublicSend
keys = get_store_class(store).keys(relation)
result[store] = keys if keys.present?
end
end end
## ##
# FastDestroyAll concerns # FastDestroyAll concerns
def finalize_fast_destroy(keys) def finalize_fast_destroy(keys)
redis_delete_data(keys) keys.each do |store, value|
get_store_class(store).delete_keys(value)
end
end end
end end
...@@ -66,10 +70,15 @@ module Ci ...@@ -66,10 +70,15 @@ module Ci
end end
def append(new_data, offset) def append(new_data, offset)
raise ArgumentError, 'New data is missing' unless new_data
raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0
raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize) raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize)
set_data(data.byteslice(0, offset) + new_data) in_lock(*lock_params) do # Write opetation is atomic
unsafe_set_data!(data.byteslice(0, offset) + new_data)
end
schedule_to_persist if full?
end end
def size def size
...@@ -88,93 +97,63 @@ module Ci ...@@ -88,93 +97,63 @@ module Ci
(start_offset...end_offset) (start_offset...end_offset)
end end
def use_database! def persist_data!
in_lock do in_lock(*lock_params) do # Write opetation is atomic
break if db? unsafe_persist_to!(self.class.persistable_store)
break unless size > 0
self.update!(raw_data: data, data_store: :db)
self.class.redis_delete_data([redis_data_key])
end end
end end
private private
def get_data def unsafe_persist_to!(new_store)
if redis? return if data_store == new_store.to_s
redis_data raise ArgumentError, 'Can not persist empty data' unless size > 0
elsif db?
raw_data
else
raise 'Unsupported data store'
end&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default
end
def set_data(value) old_store_class = self.class.get_store_class(data_store)
raise ArgumentError, 'too much data' if value.bytesize > CHUNK_SIZE
in_lock do get_data.tap do |the_data|
if redis? self.raw_data = nil
redis_set_data(value) self.data_store = new_store
elsif db? unsafe_set_data!(the_data)
self.raw_data = value
else
raise 'Unsupported data store'
end end
@data = value old_store_class.delete_data(self)
save! if changed?
end end
schedule_to_db if full? def get_data
self.class.get_store_class(data_store).data(self)&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default
rescue Excon::Error::NotFound
# If the data store is :fog and the file does not exist in the object storage, this method returns nil.
end end
def schedule_to_db def unsafe_set_data!(value)
return if db? raise ArgumentError, 'New data size exceeds chunk size' if value.bytesize > CHUNK_SIZE
Ci::BuildTraceChunkFlushWorker.perform_async(id) self.class.get_store_class(data_store).set_data(self, value)
end @data = value
def full? save! if changed?
size == CHUNK_SIZE
end end
def redis_data def schedule_to_persist
Gitlab::Redis::SharedState.with do |redis| return if data_persisted?
redis.get(redis_data_key)
end
end
def redis_set_data(data) Ci::BuildTraceChunkFlushWorker.perform_async(id)
Gitlab::Redis::SharedState.with do |redis|
redis.set(redis_data_key, data, ex: CHUNK_REDIS_TTL)
end
end end
def redis_data_key def data_persisted?
self.class.redis_data_key(build_id, chunk_index) !redis?
end end
def in_lock def full?
write_lock_key = "trace_write:#{build_id}:chunks:#{chunk_index}" size == CHUNK_SIZE
lease = Gitlab::ExclusiveLease.new(write_lock_key, timeout: WRITE_LOCK_TTL)
retry_count = 0
until uuid = lease.try_obtain
# Keep trying until we obtain the lease. To prevent hammering Redis too
# much we'll wait for a bit between retries.
sleep(WRITE_LOCK_SLEEP)
break if WRITE_LOCK_RETRY < (retry_count += 1)
end end
raise WriteError, 'Failed to obtain write lock' unless uuid def lock_params
["trace_write:#{build_id}:chunks:#{chunk_index}",
self.reload if self.persisted? { ttl: WRITE_LOCK_TTL,
return yield retries: WRITE_LOCK_RETRY,
ensure sleep_sec: WRITE_LOCK_SLEEP }]
Gitlab::ExclusiveLease.cancel(write_lock_key, uuid)
end end
end end
end end
module Ci
module BuildTraceChunks
class Database
def available?
true
end
def keys(relation)
[]
end
def delete_keys(keys)
# no-op
end
def data(model)
model.raw_data
end
def set_data(model, data)
model.raw_data = data
end
def delete_data(model)
model.update_columns(raw_data: nil) unless model.raw_data.nil?
end
end
end
end
module Ci
module BuildTraceChunks
class Fog
def available?
object_store.enabled
end
def data(model)
connection.get_object(bucket_name, key(model))[:body]
end
def set_data(model, data)
connection.put_object(bucket_name, key(model), data)
end
def delete_data(model)
delete_keys([[model.build_id, model.chunk_index]])
end
def keys(relation)
return [] unless available?
relation.pluck(:build_id, :chunk_index)
end
def delete_keys(keys)
keys.each do |key|
connection.delete_object(bucket_name, key_raw(*key))
end
end
private
def key(model)
key_raw(model.build_id, model.chunk_index)
end
def key_raw(build_id, chunk_index)
"tmp/builds/#{build_id.to_i}/chunks/#{chunk_index.to_i}.log"
end
def bucket_name
return unless available?
object_store.remote_directory
end
def connection
return unless available?
@connection ||= ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys)
end
def object_store
Gitlab.config.artifacts.object_store
end
end
end
end
module Ci
module BuildTraceChunks
class Redis
CHUNK_REDIS_TTL = 1.week
def available?
true
end
def data(model)
Gitlab::Redis::SharedState.with do |redis|
redis.get(key(model))
end
end
def set_data(model, data)
Gitlab::Redis::SharedState.with do |redis|
redis.set(key(model), data, ex: CHUNK_REDIS_TTL)
end
end
def delete_data(model)
delete_keys([[model.build_id, model.chunk_index]])
end
def keys(relation)
relation.pluck(:build_id, :chunk_index)
end
def delete_keys(keys)
return if keys.empty?
keys = keys.map { |key| key_raw(*key) }
Gitlab::Redis::SharedState.with do |redis|
redis.del(keys)
end
end
private
def key(model)
key_raw(model.build_id, model.chunk_index)
end
def key_raw(build_id, chunk_index)
"gitlab:ci:trace:#{build_id.to_i}:chunks:#{chunk_index.to_i}"
end
end
end
end
...@@ -40,6 +40,18 @@ module CacheMarkdownField ...@@ -40,6 +40,18 @@ module CacheMarkdownField
end end
end end
class MarkdownEngine
def self.from_version(version = nil)
return :common_mark if version.nil? || version == 0
if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
:redcarpet
else
:common_mark
end
end
end
def skip_project_check? def skip_project_check?
false false
end end
...@@ -57,7 +69,7 @@ module CacheMarkdownField ...@@ -57,7 +69,7 @@ module CacheMarkdownField
# Banzai is less strict about authors, so don't always have an author key # Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author) context[:author] = self.author if self.respond_to?(:author)
context[:markdown_engine] = markdown_engine context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version)
context context
end end
...@@ -123,14 +135,6 @@ module CacheMarkdownField ...@@ -123,14 +135,6 @@ module CacheMarkdownField
end end
end end
def markdown_engine
if latest_cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
:redcarpet
else
:common_mark
end
end
included do included do
cattr_reader :cached_markdown_fields do cattr_reader :cached_markdown_fields do
FieldData.new FieldData.new
......
...@@ -7,7 +7,7 @@ module CacheableAttributes ...@@ -7,7 +7,7 @@ module CacheableAttributes
class_methods do class_methods do
def cache_key def cache_key
"#{name}:#{Gitlab::VERSION}:#{Gitlab.migrations_hash}:#{Rails.version}".freeze "#{name}:#{Gitlab::VERSION}:#{Rails.version}".freeze
end end
# Can be overriden # Can be overriden
...@@ -69,6 +69,6 @@ module CacheableAttributes ...@@ -69,6 +69,6 @@ module CacheableAttributes
end end
def cache! def cache!
Rails.cache.write(self.class.cache_key, self) Rails.cache.write(self.class.cache_key, self, expires_in: 1.minute)
end end
end end
...@@ -44,8 +44,8 @@ module GroupDescendant ...@@ -44,8 +44,8 @@ module GroupDescendant
This error is not user facing, but causes a +1 query. This error is not user facing, but causes a +1 query.
MSG MSG
extras = { extras = {
parent: parent, parent: parent.inspect,
child: child, child: child.inspect,
preloaded: preloaded.map(&:full_path) preloaded: preloaded.map(&:full_path)
} }
issue_url = 'https://gitlab.com/gitlab-org/gitlab-ce/issues/40785' issue_url = 'https://gitlab.com/gitlab-org/gitlab-ce/issues/40785'
......
class ImportExportUpload < ActiveRecord::Base
include WithUploads
include ObjectStorage::BackgroundMove
belongs_to :project
mount_uploader :import_file, ImportExportUploader
mount_uploader :export_file, ImportExportUploader
def retrieve_upload(_identifier, paths)
Upload.find_by(model: self, path: paths)
end
end
...@@ -131,9 +131,10 @@ class Milestone < ActiveRecord::Base ...@@ -131,9 +131,10 @@ class Milestone < ActiveRecord::Base
rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id') rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id')
else else
rel rel
.group(:project_id) .group(:project_id, :due_date, :id)
.having('due_date = MIN(due_date)') .having('due_date = MIN(due_date)')
.pluck(:id, :project_id, :due_date) .pluck(:id, :project_id, :due_date)
.uniq(&:second)
.map(&:first) .map(&:first)
end end
end end
......
...@@ -171,6 +171,7 @@ class Project < ActiveRecord::Base ...@@ -171,6 +171,7 @@ class Project < ActiveRecord::Base
has_one :fork_network, through: :fork_network_member has_one :fork_network, through: :fork_network_member
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# Merge Requests for target project should be removed with it # Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id' has_many :merge_requests, foreign_key: 'target_project_id'
...@@ -1712,7 +1713,7 @@ class Project < ActiveRecord::Base ...@@ -1712,7 +1713,7 @@ class Project < ActiveRecord::Base
:started :started
elsif after_export_in_progress? elsif after_export_in_progress?
:after_export_action :after_export_action
elsif export_project_path elsif export_project_path || export_project_object_exists?
:finished :finished
else else
:none :none
...@@ -1727,16 +1728,21 @@ class Project < ActiveRecord::Base ...@@ -1727,16 +1728,21 @@ class Project < ActiveRecord::Base
import_export_shared.after_export_in_progress? import_export_shared.after_export_in_progress?
end end
def remove_exports def remove_exports(path = export_path)
return nil unless export_path.present? if path.present?
FileUtils.rm_rf(path)
FileUtils.rm_rf(export_path) elsif export_project_object_exists?
import_export_upload.remove_export_file!
import_export_upload.save
end
end end
def remove_exported_project_file def remove_exported_project_file
return unless export_project_path.present? remove_exports(export_project_path)
end
FileUtils.rm_f(export_project_path) def export_project_object_exists?
Gitlab::ImportExport.object_storage? && import_export_upload&.export_file&.file
end end
def full_path_slug def full_path_slug
......
...@@ -83,7 +83,7 @@ class Repository ...@@ -83,7 +83,7 @@ class Repository
@raw_repository&.cleanup @raw_repository&.cleanup
end end
# Return absolute path to repository # Don't use this! It's going away. Use Gitaly to read or write from repos.
def path_to_repo def path_to_repo
@path_to_repo ||= @path_to_repo ||=
begin begin
...@@ -250,7 +250,7 @@ class Repository ...@@ -250,7 +250,7 @@ class Repository
# This will still fail if the file is corrupted (e.g. 0 bytes) # This will still fail if the file is corrupted (e.g. 0 bytes)
raw_repository.write_ref(keep_around_ref_name(sha), sha, shell: false) raw_repository.write_ref(keep_around_ref_name(sha), sha, shell: false)
rescue Gitlab::Git::CommandError => ex rescue Gitlab::Git::CommandError => ex
Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}" Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}"
end end
def kept_around?(sha) def kept_around?(sha)
...@@ -564,7 +564,7 @@ class Repository ...@@ -564,7 +564,7 @@ class Repository
end end
def rendered_readme def rendered_readme
MarkupHelper.markup_unsafe(readme.name, readme.data, project: project) if readme MarkupHelper.markup_unsafe(readme.name, readme.data, project: project, markdown_engine: :redcarpet) if readme
end end
cache_method :rendered_readme cache_method :rendered_readme
......
...@@ -72,6 +72,19 @@ class GroupPolicy < BasePolicy ...@@ -72,6 +72,19 @@ class GroupPolicy < BasePolicy
enable :change_visibility_level enable :change_visibility_level
end end
rule { can?(:read_nested_project_resources) }.policy do
enable :read_group_activity
enable :read_group_issues
enable :read_group_boards
enable :read_group_labels
enable :read_group_milestones
enable :read_group_merge_requests
end
rule { can?(:read_cross_project) & can?(:read_group) }.policy do
enable :read_nested_project_resources
end
rule { owner & nested_groups_supported }.enable :create_subgroup rule { owner & nested_groups_supported }.enable :create_subgroup
rule { public_group | logged_in_viewable }.enable :view_globally rule { public_group | logged_in_viewable }.enable :view_globally
......
...@@ -62,6 +62,8 @@ class NoteEntity < API::Entities::Note ...@@ -62,6 +62,8 @@ class NoteEntity < API::Entities::Note
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
expose :cached_markdown_version
private private
def current_user def current_user
......
...@@ -10,7 +10,9 @@ class ImportExportCleanUpService ...@@ -10,7 +10,9 @@ class ImportExportCleanUpService
def execute def execute
Gitlab::Metrics.measure(:import_export_clean_up) do Gitlab::Metrics.measure(:import_export_clean_up) do
next unless File.directory?(path) clean_up_export_object_files
break unless File.directory?(path)
clean_up_export_files clean_up_export_files
end end
...@@ -21,4 +23,11 @@ class ImportExportCleanUpService ...@@ -21,4 +23,11 @@ class ImportExportCleanUpService
def clean_up_export_files def clean_up_export_files
Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete)) Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete))
end end
def clean_up_export_object_files
ImportExportUpload.where('updated_at < ?', mmin.minutes.ago).each do |upload|
upload.remove_export_file!
upload.save!
end
end
end end
...@@ -135,6 +135,8 @@ class NotificationService ...@@ -135,6 +135,8 @@ class NotificationService
# * watchers of the mr's labels # * watchers of the mr's labels
# * users with custom level checked with "new merge request" # * users with custom level checked with "new merge request"
# #
# In EE, approvers of the merge request are also included
#
def new_merge_request(merge_request, current_user) def new_merge_request(merge_request, current_user)
new_resource_email(merge_request, :new_merge_request_email) new_resource_email(merge_request, :new_merge_request_email)
end end
...@@ -256,6 +258,10 @@ class NotificationService ...@@ -256,6 +258,10 @@ class NotificationService
# ignore gitlab service messages # ignore gitlab service messages
return true if note.cross_reference? && note.system? return true if note.cross_reference? && note.system?
send_new_note_notifications(note)
end
def send_new_note_notifications(note)
notify_method = "note_#{note.to_ability_name}_email".to_sym notify_method = "note_#{note.to_ability_name}_email".to_sym
recipients = NotificationRecipientService.build_new_note_recipients(note) recipients = NotificationRecipientService.build_new_note_recipients(note)
......
...@@ -6,7 +6,8 @@ class PreviewMarkdownService < BaseService ...@@ -6,7 +6,8 @@ class PreviewMarkdownService < BaseService
success( success(
text: text, text: text,
users: users, users: users,
commands: commands.join(' ') commands: commands.join(' '),
markdown_engine: markdown_engine
) )
end end
...@@ -42,4 +43,8 @@ class PreviewMarkdownService < BaseService ...@@ -42,4 +43,8 @@ class PreviewMarkdownService < BaseService
def commands_target_id def commands_target_id
params[:quick_actions_target_id] params[:quick_actions_target_id]
end end
def markdown_engine
CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i)
end
end end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment