Commit d3e81673 authored by Stan Hu's avatar Stan Hu

Merge branch 'master' into sh-headless-chrome-support

parents 767dffcc 441ba5f3
......@@ -226,6 +226,7 @@ update-tests-metadata:
flaky-examples-check:
<<: *dedicated-runner
<<: *except-docs
image: ruby:2.3-alpine
services: []
before_script: []
......
......@@ -2,6 +2,27 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 9.4.5 (2017-08-14)
- Fix deletion of deploy keys linked to other projects. !13162
- Allow any logged in users to read_users_list even if it's restricted. !13201
- Make Delete Merged Branches handle wildcard protected branches correctly. !13251
- Fix an order of operations for CI connection error message in merge request widget. !13252
- Fix pipeline_schedules pages when active schedule has an abnormal state. !13286
- Add missing validation error for username change with container registry tags. !13356
- Fix destroy of case-insensitive conflicting redirects. !13357
- Project pending delete no longer return 500 error in admins projects view. !13389
- Fix search box losing focus when typing.
- Use jQuery to control scroll behavior in job log for cross browser consistency.
- Use project_ref_path to create the link to a branch to fix links that 404.
- improve file upload/replace experience.
- fix jump to next discussion button.
- Fixes new issue button for failed job returning 404.
- Fix links to group milestones from issue and merge request sidebar.
- Fixed sign-in restrictions buttons not toggling active state.
- Fix Mattermost integration.
- Change project FK migration to skip existing FKs.
## 9.4.4 (2017-08-09)
- Remove hidden symlinks from project import files.
......
......@@ -84,7 +84,7 @@ gem 'rack-cors', '~> 0.4.0', require: 'rack/cors'
gem 'hashie-forbidden_attributes'
# Pagination
gem 'kaminari', '~> 0.17.0'
gem 'kaminari', '~> 1.0'
# HAML
gem 'hamlit', '~> 2.6.1'
......@@ -324,6 +324,7 @@ group :development, :test do
gem 'spinach-rerun-reporter', '~> 0.0.2'
gem 'rspec_profiling', '~> 0.0.5'
gem 'rspec-set', '~> 0.1.3'
gem 'rspec-parameterized'
# Prevent occasions where minitest is not bundled in packaged versions of ruby (see #3826)
gem 'minitest', '~> 5.7.0'
......
......@@ -2,6 +2,7 @@ GEM
remote: https://rubygems.org/
specs:
RedCloth (4.3.2)
abstract_type (0.0.7)
ace-rails-ap (4.1.2)
actionmailer (4.2.8)
actionpack (= 4.2.8)
......@@ -41,6 +42,9 @@ GEM
tzinfo (~> 1.1)
acts-as-taggable-on (4.0.0)
activerecord (>= 4.0)
adamantium (0.2.0)
ice_nine (~> 0.11.0)
memoizable (~> 0.4.0)
addressable (2.3.8)
after_commit_queue (1.3.0)
activerecord (>= 3.0)
......@@ -125,6 +129,9 @@ GEM
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
colorize (0.7.7)
concord (0.1.5)
adamantium (~> 0.2.0)
equalizer (~> 0.0.9)
concurrent-ruby (1.0.5)
concurrent-ruby-ext (1.0.5)
concurrent-ruby (= 1.0.5)
......@@ -420,9 +427,18 @@ GEM
json-schema (2.6.2)
addressable (~> 2.3.8)
jwt (1.5.6)
kaminari (0.17.0)
actionpack (>= 3.0.0)
activesupport (>= 3.0.0)
kaminari (1.0.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.0.1)
kaminari-activerecord (= 1.0.1)
kaminari-core (= 1.0.1)
kaminari-actionview (1.0.1)
actionview
kaminari-core (= 1.0.1)
kaminari-activerecord (1.0.1)
activerecord
kaminari-core (= 1.0.1)
kaminari-core (1.0.1)
kgio (2.10.0)
knapsack (1.11.0)
rake
......@@ -462,6 +478,8 @@ GEM
mime-types (>= 1.16, < 4)
mail_room (0.9.1)
memoist (0.15.0)
memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1)
method_source (0.8.2)
mime-types (2.99.3)
mimemagic (0.3.0)
......@@ -598,6 +616,11 @@ GEM
premailer-rails (1.9.7)
actionmailer (>= 3, < 6)
premailer (~> 1.7, >= 1.7.9)
proc_to_ast (0.1.0)
coderay
parser
unparser
procto (0.0.3)
prometheus-client-mmap (0.7.0.beta11)
mmap2 (~> 2.2, >= 2.2.7)
pry (0.10.4)
......@@ -706,6 +729,10 @@ GEM
chunky_png
rqrcode-rails3 (0.1.7)
rqrcode (>= 0.4.2)
rspec (3.6.0)
rspec-core (~> 3.6.0)
rspec-expectations (~> 3.6.0)
rspec-mocks (~> 3.6.0)
rspec-core (3.6.0)
rspec-support (~> 3.6.0)
rspec-expectations (3.6.0)
......@@ -714,6 +741,12 @@ GEM
rspec-mocks (3.6.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.6.0)
rspec-parameterized (0.4.0)
binding_of_caller
parser
proc_to_ast
rspec (>= 2.13, < 4)
unparser
rspec-rails (3.6.0)
actionpack (>= 3.0)
activesupport (>= 3.0)
......@@ -883,6 +916,14 @@ GEM
get_process_mem (~> 0)
unicorn (>= 4, < 6)
uniform_notifier (1.10.0)
unparser (0.2.6)
abstract_type (~> 0.0.7)
adamantium (~> 0.2.0)
concord (~> 0.1.5)
diff-lcs (~> 1.3)
equalizer (~> 0.0.9)
parser (>= 2.3.1.2, < 2.5)
procto (~> 0.0.2)
url_safe_base64 (0.2.2)
validates_hostname (1.0.6)
activerecord (>= 3.0)
......@@ -1008,7 +1049,7 @@ DEPENDENCIES
jquery-rails (~> 4.1.0)
json-schema (~> 2.6.2)
jwt (~> 1.5.6)
kaminari (~> 0.17.0)
kaminari (~> 1.0)
knapsack (~> 1.11.0)
kubeclient (~> 2.2.0)
letter_opener_web (~> 1.3.0)
......@@ -1081,6 +1122,7 @@ DEPENDENCIES
responders (~> 2.0)
rouge (~> 2.0)
rqrcode-rails3 (~> 0.1.7)
rspec-parameterized
rspec-rails (~> 3.6.0)
rspec-retry (~> 0.4.5)
rspec-set (~> 0.1.3)
......
......@@ -97,7 +97,6 @@ const Api = {
},
commitMultiple(id, data, callback) {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
const url = Api.buildUrl(Api.commitPath)
.replace(':id', id);
return $.ajax({
......
......@@ -347,6 +347,9 @@ import initChangesDropdown from './init_changes_dropdown';
if ($('#tree-slider').length) new TreeView();
if ($('.blob-viewer').length) new BlobViewer();
if ($('.project-show-activity').length) new gl.Activities();
$('#tree-slider').waitForImages(function() {
gl.utils.ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
});
break;
case 'projects:edit':
setupProjectEdit();
......
import Cookies from 'js-cookie';
import bp from './breakpoints';
const HIDE_INTERVAL_TIMEOUT = 300;
const IS_OVER_CLASS = 'is-over';
const IS_ABOVE_CLASS = 'is-above';
const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out';
let currentOpenMenu = null;
let menuCornerLocs;
let timeoutId;
export const mousePos = [];
export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; };
export const slope = (a, b) => (b.y - a.y) / (b.x - a.x);
export const canShowActiveSubItems = (el) => {
const isHiddenByMedia = bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md';
......@@ -10,8 +24,28 @@ export const canShowActiveSubItems = (el) => {
return true;
};
export const canShowSubItems = () => bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md' || bp.getBreakpointSize() === 'lg';
export const getHideSubItemsInterval = () => {
if (!currentOpenMenu) return 0;
const currentMousePos = mousePos[mousePos.length - 1];
const prevMousePos = mousePos[0];
const currentMousePosY = currentMousePos.y;
const [menuTop, menuBottom] = menuCornerLocs;
if (currentMousePosY < menuTop.y ||
currentMousePosY > menuBottom.y) return 0;
if (slope(prevMousePos, menuBottom) < slope(currentMousePos, menuBottom) &&
slope(prevMousePos, menuTop) > slope(currentMousePos, menuTop)) {
return HIDE_INTERVAL_TIMEOUT;
}
return 0;
};
export const calculateTop = (boundingRect, outerHeight) => {
const windowHeight = window.innerHeight;
const bottomOverflow = windowHeight - (boundingRect.top + outerHeight);
......@@ -20,45 +54,118 @@ export const calculateTop = (boundingRect, outerHeight) => {
boundingRect.top;
};
export const showSubLevelItems = (el) => {
const subItems = el.querySelector('.sidebar-sub-level-items');
export const hideMenu = (el) => {
if (!el) return;
if (!subItems || !canShowSubItems() || !canShowActiveSubItems(el)) return;
const parentEl = el.parentNode;
subItems.style.display = 'block';
el.classList.add('is-showing-fly-out');
el.classList.add('is-over');
el.style.display = ''; // eslint-disable-line no-param-reassign
el.style.transform = ''; // eslint-disable-line no-param-reassign
el.classList.remove(IS_ABOVE_CLASS);
parentEl.classList.remove(IS_OVER_CLASS);
parentEl.classList.remove(IS_SHOWING_FLY_OUT_CLASS);
setOpenMenu();
};
export const moveSubItemsToPosition = (el, subItems) => {
const boundingRect = el.getBoundingClientRect();
const top = calculateTop(boundingRect, subItems.offsetHeight);
const isAbove = top < boundingRect.top;
subItems.classList.add('fly-out-list');
subItems.style.transform = `translate3d(0, ${Math.floor(top)}px, 0)`;
subItems.style.transform = `translate3d(0, ${Math.floor(top)}px, 0)`; // eslint-disable-line no-param-reassign
const subItemsRect = subItems.getBoundingClientRect();
menuCornerLocs = [
{
x: subItemsRect.left, // left position of the sub items
y: subItemsRect.top, // top position of the sub items
},
{
x: subItemsRect.left, // left position of the sub items
y: subItemsRect.top + subItemsRect.height, // bottom position of the sub items
},
];
if (isAbove) {
subItems.classList.add('is-above');
subItems.classList.add(IS_ABOVE_CLASS);
}
};
export const hideSubLevelItems = (el) => {
export const showSubLevelItems = (el) => {
const subItems = el.querySelector('.sidebar-sub-level-items');
if (!canShowSubItems() || !canShowActiveSubItems(el)) return;
el.classList.add(IS_OVER_CLASS);
if (!subItems) return;
subItems.style.display = 'block';
el.classList.add(IS_SHOWING_FLY_OUT_CLASS);
setOpenMenu(subItems);
moveSubItemsToPosition(el, subItems);
};
export const mouseEnterTopItems = (el) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (currentOpenMenu) hideMenu(currentOpenMenu);
showSubLevelItems(el);
}, getHideSubItemsInterval());
};
export const mouseLeaveTopItem = (el) => {
const subItems = el.querySelector('.sidebar-sub-level-items');
if (!subItems || !canShowSubItems() || !canShowActiveSubItems(el)) return;
if (!canShowSubItems() || !canShowActiveSubItems(el) ||
(subItems && subItems === currentOpenMenu)) return;
el.classList.remove(IS_OVER_CLASS);
};
export const documentMouseMove = (e) => {
mousePos.push({
x: e.clientX,
y: e.clientY,
});
el.classList.remove('is-showing-fly-out');
el.classList.remove('is-over');
subItems.style.display = '';
subItems.style.transform = '';
subItems.classList.remove('is-above');
if (mousePos.length > 6) mousePos.shift();
};
export default () => {
const items = [...document.querySelectorAll('.sidebar-top-level-items > li')]
.filter(el => el.querySelector('.sidebar-sub-level-items'));
const sidebar = document.querySelector('.sidebar-top-level-items');
if (!sidebar) return;
const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')];
sidebar.addEventListener('mouseleave', () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (currentOpenMenu) hideMenu(currentOpenMenu);
}, getHideSubItemsInterval());
});
items.forEach((el) => {
el.addEventListener('mouseenter', e => showSubLevelItems(e.currentTarget));
el.addEventListener('mouseleave', e => hideSubLevelItems(e.currentTarget));
const subItems = el.querySelector('.sidebar-sub-level-items');
if (subItems) {
subItems.addEventListener('mouseleave', () => {
clearTimeout(timeoutId);
hideMenu(currentOpenMenu);
});
}
el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget));
el.addEventListener('mouseleave', e => mouseLeaveTopItem(e.currentTarget));
});
document.addEventListener('mousemove', documentMouseMove);
};
export default class GpgBadges {
static fetch() {
const badges = $('.js-loading-gpg-badge');
const form = $('.commits-search-form');
badges.html('<i class="fa fa-spinner fa-spin"></i>');
$.get({
url: form.data('signatures-path'),
data: form.serialize(),
}).done((response) => {
const badges = $('.js-loading-gpg-badge');
response.signatures.forEach((signature) => {
badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html);
});
......
import Vue from 'vue';
import Cookies from 'js-cookie';
import Translate from '../../vue_shared/translate';
import illustrationSvg from '../icons/intro_illustration.svg';
Vue.use(Translate);
const cookieKey = 'pipeline_schedules_callout_dismissed';
export default {
name: 'PipelineSchedulesCallout',
data() {
return {
docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
illustrationSvg,
calloutDismissed: Cookies.get(cookieKey) === 'true',
};
},
methods: {
dismissCallout() {
this.calloutDismissed = true;
Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
},
},
template: `
<div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout">
<div class="bordered-box landing content-block">
<button
id="dismiss-callout-btn"
class="btn btn-default close"
@click="dismissCallout">
<i class="fa fa-times"></i>
</button>
<div class="svg-container" v-html="illustrationSvg"></div>
<div class="user-callout-copy">
<h4>{{ __('Scheduling Pipelines') }}</h4>
<p>
{{ __('The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user.') }}
</p>
<p> {{ __('Learn more in the') }}
<a
:href="docsUrl"
target="_blank"
rel="nofollow">{{ s__('Learn more in the|pipeline schedules documentation') }}</a>. <!-- oneline to prevent extra space before period -->
</p>
</div>
</div>
</div>
`,
};
<script>
import Vue from 'vue';
import Cookies from 'js-cookie';
import Translate from '../../vue_shared/translate';
import illustrationSvg from '../icons/intro_illustration.svg';
Vue.use(Translate);
const cookieKey = 'pipeline_schedules_callout_dismissed';
export default {
name: 'PipelineSchedulesCallout',
data() {
return {
docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
calloutDismissed: Cookies.get(cookieKey) === 'true',
};
},
methods: {
dismissCallout() {
this.calloutDismissed = true;
Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
},
},
created() {
this.illustrationSvg = illustrationSvg;
},
};
</script>
<template>
<div
v-if="!calloutDismissed"
class="pipeline-schedules-user-callout user-callout">
<div class="bordered-box landing content-block">
<button
id="dismiss-callout-btn"
class="btn btn-default close"
@click="dismissCallout">
<i
aria-hidden="true"
class="fa fa-times">
</i>
</button>
<div class="svg-container" v-html="illustrationSvg"></div>
<div class="user-callout-copy">
<h4>{{ __('Scheduling Pipelines') }}</h4>
<p>
{{ __('The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user.') }}
</p>
<p> {{ __('Learn more in the') }}
<a
:href="docsUrl"
target="_blank"
rel="nofollow">{{ s__('Learn more in the|pipeline schedules documentation') }}</a>. <!-- oneline to prevent extra space before period -->
</p>
</div>
</div>
</div>
</template>
import Vue from 'vue';
import PipelineSchedulesCallout from './components/pipeline_schedules_callout';
import PipelineSchedulesCallout from './components/pipeline_schedules_callout.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#pipeline-schedules-callout',
......
......@@ -48,6 +48,27 @@
return `${this.job.name} - ${this.job.status.label}`;
},
},
methods: {
/**
* When the user right clicks or cmd/ctrl + click in the job name
* the dropdown should not be closed and the link should open in another tab,
* so we stop propagation of the click event inside the dropdown.
*
* Since this component is rendered multiple times per page we need to guarantee we only
* target the click event of this component.
*/
stopDropdownClickPropagation() {
$(this.$el.querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
.on('click', (e) => {
e.stopPropagation();
});
},
},
mounted() {
this.stopDropdownClickPropagation();
},
};
</script>
<template>
......
......@@ -126,11 +126,11 @@ import Cookies from 'js-cookie';
var $form = $dropdown.closest('form');
var $visit = $dropdown.data('visit');
var shouldVisit = typeof $visit === 'undefined' ? true : $visit;
var shouldVisit = $visit ? true : $visit;
var action = $form.attr('action');
var divider = action.indexOf('?') === -1 ? '?' : '&';
if (shouldVisit) {
gl.utils.visitUrl(action + '' + divider + '' + $form.serialize());
gl.utils.visitUrl(`${action}${divider}${$form.serialize()}`);
}
}
}
......
......@@ -14,13 +14,13 @@ export default {
data: () => Store,
mixins: [RepoMixin],
components: {
'repo-sidebar': RepoSidebar,
'repo-tabs': RepoTabs,
'repo-file-buttons': RepoFileButtons,
RepoSidebar,
RepoTabs,
RepoFileButtons,
'repo-editor': MonacoLoaderHelper.repoEditorLoader,
'repo-commit-section': RepoCommitSection,
'popup-dialog': PopupDialog,
'repo-preview': RepoPreview,
RepoCommitSection,
PopupDialog,
RepoPreview,
},
mounted() {
......@@ -28,12 +28,12 @@ export default {
},
methods: {
dialogToggled(toggle) {
toggleDialogOpen(toggle) {
this.dialog.open = toggle;
},
dialogSubmitted(status) {
this.dialog.open = false;
this.toggleDialogOpen(false);
this.dialog.status = status;
},
......@@ -43,21 +43,25 @@ export default {
</script>
<template>
<div class="repository-view tree-content-holder">
<repo-sidebar/><div class="panel-right" :class="{'edit-mode': editMode}">
<div class="repository-view tree-content-holder">
<repo-sidebar/><div v-if="isMini"
class="panel-right"
:class="{'edit-mode': editMode}">
<repo-tabs/>
<component :is="currentBlobView" class="blob-viewer-container"></component>
<component
:is="currentBlobView"
class="blob-viewer-container"/>
<repo-file-buttons/>
</div>
<repo-commit-section/>
<popup-dialog
v-show="dialog.open"
:primary-button-label="__('Discard changes')"
:open="dialog.open"
kind="warning"
:title="__('Are you sure?')"
:body="__('Are you sure you want to discard your changes?')"
@toggle="dialogToggled"
@toggle="toggleDialogOpen"
@submit="dialogSubmitted"
/>
</div>
</div>
</template>
......@@ -2,18 +2,20 @@
/* global Flash */
import Store from '../stores/repo_store';
import RepoMixin from '../mixins/repo_mixin';
import Helper from '../helpers/repo_helper';
import Service from '../services/repo_service';
const RepoCommitSection = {
export default {
data: () => Store,
mixins: [RepoMixin],
computed: {
showCommitable() {
return this.isCommitable && this.changedFiles.length;
},
branchPaths() {
const branch = Helper.getBranch();
return this.changedFiles.map(f => Helper.getFilePathFromFullPath(f.url, branch));
return this.changedFiles.map(f => f.path);
},
cantCommitYet() {
......@@ -28,11 +30,10 @@ const RepoCommitSection = {
methods: {
makeCommit() {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
const branch = Helper.getBranch();
const commitMessage = this.commitMessage;
const actions = this.changedFiles.map(f => ({
action: 'update',
file_path: Helper.getFilePathFromFullPath(f.url, branch),
file_path: f.path,
content: f.newContent,
}));
const payload = {
......@@ -47,51 +48,80 @@ const RepoCommitSection = {
resetCommitState() {
this.submitCommitsLoading = false;
this.changedFiles = [];
this.openedFiles = [];
this.commitMessage = '';
this.editMode = false;
$('html, body').animate({ scrollTop: 0 }, 'fast');
window.scrollTo(0, 0);
},
},
};
export default RepoCommitSection;
</script>
<template>
<div id="commit-area" v-if="isCommitable && changedFiles.length" >
<form class="form-horizontal">
<div
v-if="showCommitable"
id="commit-area">
<form
class="form-horizontal"
@submit.prevent="makeCommit">
<fieldset>
<div class="form-group">
<label class="col-md-4 control-label staged-files">Staged files ({{changedFiles.length}})</label>
<div class="col-md-4">
<label class="col-md-4 control-label staged-files">
Staged files ({{changedFiles.length}})
</label>
<div class="col-md-6">
<ul class="list-unstyled changed-files">
<li v-for="file in branchPaths" :key="file.id">
<span class="help-block">{{file}}</span>
<li
v-for="branchPath in branchPaths"
:key="branchPath">
<span class="help-block">
{{branchPath}}
</span>
</li>
</ul>
</div>
</div>
<!-- Textarea
-->
<div class="form-group">
<label class="col-md-4 control-label" for="commit-message">Commit message</label>
<div class="col-md-4">
<textarea class="form-control" id="commit-message" name="commit-message" v-model="commitMessage"></textarea>
<label
class="col-md-4 control-label"
for="commit-message">
Commit message
</label>
<div class="col-md-6">
<textarea
id="commit-message"
class="form-control"
name="commit-message"
v-model="commitMessage">
</textarea>
</div>
</div>
<!-- Button Drop Down
-->
<div class="form-group target-branch">
<label class="col-md-4 control-label" for="target-branch">Target branch</label>
<div class="col-md-4">
<span class="help-block">{{targetBranch}}</span>
<label
class="col-md-4 control-label"
for="target-branch">
Target branch
</label>
<div class="col-md-6">
<span class="help-block">
{{targetBranch}}
</span>
</div>
</div>
<div class="col-md-offset-4 col-md-4">
<button type="submit" :disabled="cantCommitYet" class="btn btn-success submit-commit" @click.prevent="makeCommit">
<i class="fa fa-spinner fa-spin" v-if="submitCommitsLoading"></i>
<span class="commit-summary">Commit {{changedFiles.length}} {{filePluralize}}</span>
<div class="col-md-offset-4 col-md-6">
<button
ref="submitCommit"
type="submit"
:disabled="cantCommitYet"
class="btn btn-success">
<i
v-if="submitCommitsLoading"
class="fa fa-spinner fa-spin"
aria-hidden="true"
aria-label="loading">
</i>
<span class="commit-summary">
Commit {{changedFiles.length}} {{filePluralize}}
</span>
</button>
</div>
</fieldset>
......
......@@ -10,12 +10,15 @@ export default {
return this.editMode ? this.__('Cancel edit') : this.__('Edit');
},
buttonIcon() {
return this.editMode ? [] : ['fa', 'fa-pencil'];
showButton() {
return this.isCommitable &&
!this.activeFile.render_error &&
!this.binary &&
this.openedFiles.length;
},
},
methods: {
editClicked() {
editCancelClicked() {
if (this.changedFiles.length) {
this.dialog.open = true;
return;
......@@ -23,27 +26,33 @@ export default {
this.editMode = !this.editMode;
Store.toggleBlobView();
},
toggleProjectRefsForm() {
$('.project-refs-form').toggleClass('disabled', this.editMode);
$('.js-tree-ref-target-holder').toggle(this.editMode);
},
},
watch: {
editMode() {
if (this.editMode) {
$('.project-refs-form').addClass('disabled');
$('.fa-long-arrow-right').show();
$('.project-refs-target-form').show();
} else {
$('.project-refs-form').removeClass('disabled');
$('.fa-long-arrow-right').hide();
$('.project-refs-target-form').hide();
}
this.toggleProjectRefsForm();
},
},
};
</script>
<template>
<button class="btn btn-default" @click.prevent="editClicked" v-cloak v-if="isCommitable && !activeFile.render_error" :disabled="binary">
<i :class="buttonIcon"></i>
<span>{{buttonLabel}}</span>
<button
v-if="showButton"
class="btn btn-default"
type="button"
@click.prevent="editCancelClicked">
<i
v-if="!editMode"
class="fa fa-pencil"
aria-hidden="true">
</i>
<span>
{{buttonLabel}}
</span>
</button>
</template>
......@@ -8,38 +8,39 @@ const RepoEditor = {
data: () => Store,
destroyed() {
// this.monacoInstance.getModels().forEach((m) => {
// m.dispose();
// });
this.monacoInstance.destroy();
if (Helper.monacoInstance) {
Helper.monacoInstance.destroy();
}
},
mounted() {
Service.getRaw(this.activeFile.raw_path)
.then((rawResponse) => {
Store.blobRaw = rawResponse.data;
Helper.findOpenedFileFromActive().plain = rawResponse.data;
Store.activeFile.plain = rawResponse.data;
const monacoInstance = this.monaco.editor.create(this.$el, {
const monacoInstance = Helper.monaco.editor.create(this.$el, {
model: null,
readOnly: false,
contextmenu: false,
});
Store.monacoInstance = monacoInstance;
Helper.monacoInstance = monacoInstance;
this.addMonacoEvents();
const languages = this.monaco.languages.getLanguages();
const languageID = Helper.getLanguageIDForFile(this.activeFile, languages);
this.setupEditor();
})
.catch(Helper.loadingError);
},
methods: {
setupEditor() {
this.showHide();
const newModel = this.monaco.editor.createModel(this.blobRaw, languageID);
this.monacoInstance.setModel(newModel);
}).catch(Helper.loadingError);
Helper.setMonacoModelFromLanguage();
},
methods: {
showHide() {
if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) {
this.$el.style.display = 'none';
......@@ -49,41 +50,36 @@ const RepoEditor = {
},
addMonacoEvents() {
this.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp);
this.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this));
Helper.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp);
Helper.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this));
},
onMonacoEditorKeysPressed() {
Store.setActiveFileContents(this.monacoInstance.getValue());
Store.setActiveFileContents(Helper.monacoInstance.getValue());
},
onMonacoEditorMouseUp(e) {
if (!e.target.position) return;
const lineNumber = e.target.position.lineNumber;
if (e.target.element.className === 'line-numbers') {
if (e.target.element.classList.contains('line-numbers')) {
location.hash = `L${lineNumber}`;
Store.activeLine = lineNumber;
}
},
},
watch: {
activeLine() {
this.monacoInstance.setPosition({
Helper.monacoInstance.setPosition({
lineNumber: this.activeLine,
column: 1,
});
}
},
activeFileLabel() {
this.showHide();
},
watch: {
dialog: {
handler(obj) {
const newObj = obj;
if (newObj.status) {
newObj.status = false;
this.openedFiles.map((file) => {
this.openedFiles = this.openedFiles.map((file) => {
const f = file;
if (f.active) {
this.blobRaw = f.plain;
......@@ -94,35 +90,21 @@ const RepoEditor = {
return f;
});
this.editMode = false;
Store.toggleBlobView();
}
},
deep: true,
},
isTree() {
this.showHide();
},
openedFiles() {
this.showHide();
blobRaw() {
if (Helper.monacoInstance && !this.isTree) {
this.setupEditor();
}
},
binary() {
this.showHide();
},
blobRaw() {
this.showHide();
if (this.isTree) return;
this.monacoInstance.setModel(null);
const languages = this.monaco.languages.getLanguages();
const languageID = Helper.getLanguageIDForFile(this.activeFile, languages);
const newModel = this.monaco.editor.createModel(this.blobRaw, languageID);
this.monacoInstance.setModel(newModel);
computed: {
shouldHideEditor() {
return !this.openedFiles.length || (this.binary && !this.activeFile.raw);
},
},
};
......@@ -131,5 +113,5 @@ export default RepoEditor;
</script>
<template>
<div id="ide"></div>
<div id="ide" v-if='!shouldHideEditor'></div>
</template>
......@@ -33,6 +33,26 @@ const RepoFile = {
canShowFile() {
return !this.loading.tree || this.hasFiles;
},
fileIcon() {
const classObj = {
'fa-spinner fa-spin': this.file.loading,
[this.file.icon]: !this.file.loading,
};
return classObj;
},
fileIndentation() {
return {
'margin-left': `${this.file.level * 10}px`,
};
},
activeFileClass() {
return {
active: this.activeFile.url === this.file.url,
};
},
},
methods: {
......@@ -46,21 +66,42 @@ export default RepoFile;
</script>
<template>
<tr class="file" v-if="canShowFile" :class="{'active': activeFile.url === file.url}">
<td @click.prevent="linkClicked(file)">
<i class="fa file-icon" v-if="!file.loading" :class="file.icon" :style="{'margin-left': file.level * 10 + 'px'}"></i>
<i class="fa fa-spinner fa-spin" v-if="file.loading" :style="{'margin-left': file.level * 10 + 'px'}"></i>
<a :href="file.url" class="repo-file-name" :title="file.url">{{file.name}}</a>
<tr
v-if="canShowFile"
class="file"
:class="activeFileClass"
@click.prevent="linkClicked(file)">
<td>
<i
class="fa fa-fw file-icon"
:class="fileIcon"
:style="fileIndentation"
aria-label="file icon">
</i>
<a
:href="file.url"
class="repo-file-name"
:title="file.url">
{{file.name}}
</a>
</td>
<td v-if="!isMini" class="hidden-sm hidden-xs">
<template v-if="!isMini">
<td class="hidden-sm hidden-xs">
<div class="commit-message">
<a :href="file.lastCommitUrl">{{file.lastCommitMessage}}</a>
<a @click.stop :href="file.lastCommitUrl">
{{file.lastCommitMessage}}
</a>
</div>
</td>
<td v-if="!isMini" class="hidden-xs">
<span class="commit-update" :title="tooltipTitle(file.lastCommitUpdate)">{{timeFormated(file.lastCommitUpdate)}}</span>
<td class="hidden-xs">
<span
class="commit-update"
:title="tooltipTitle(file.lastCommitUpdate)">
{{timeFormated(file.lastCommitUpdate)}}
</span>
</td>
</template>
</tr>
</template>
......@@ -15,7 +15,7 @@ const RepoFileButtons = {
},
canPreview() {
return Helper.isKindaBinary();
return Helper.isRenderable();
},
},
......@@ -28,15 +28,42 @@ export default RepoFileButtons;
</script>
<template>
<div id="repo-file-buttons" v-if="isMini">
<a :href="activeFile.raw_path" target="_blank" class="btn btn-default raw" rel="noopener noreferrer">{{rawDownloadButtonLabel}}</a>
<div id="repo-file-buttons">
<a
:href="activeFile.raw_path"
target="_blank"
class="btn btn-default raw"
rel="noopener noreferrer">
{{rawDownloadButtonLabel}}
</a>
<div class="btn-group" role="group" aria-label="File actions">
<a :href="activeFile.blame_path" class="btn btn-default blame">Blame</a>
<a :href="activeFile.commits_path" class="btn btn-default history">History</a>
<a :href="activeFile.permalink" class="btn btn-default permalink">Permalink</a>
<div
class="btn-group"
role="group"
aria-label="File actions">
<a
:href="activeFile.blame_path"
class="btn btn-default blame">
Blame
</a>
<a
:href="activeFile.commits_path"
class="btn btn-default history">
History
</a>
<a
:href="activeFile.permalink"
class="btn btn-default permalink">
Permalink
</a>
</div>
<a href="#" v-if="canPreview" @click.prevent="rawPreviewToggle" class="btn btn-default preview">{{activeFileLabel}}</a>
</div>
<a
v-if="canPreview"
href="#"
@click.prevent="rawPreviewToggle"
class="btn btn-default preview">
{{activeFileLabel}}
</a>
</div>
</template>
......@@ -17,7 +17,7 @@ export default RepoFileOptions;
</script>
<template>
<tr v-if="isMini" class="repo-file-options">
<tr v-if="isMini" class="repo-file-options">
<td>
<span class="title">{{projectName}}</span>
</td>
......
......@@ -18,9 +18,15 @@ const RepoLoadingFile = {
},
},
computed: {
showGhostLines() {
return this.loading.tree && !this.hasFiles;
},
},
methods: {
lineOfCode(n) {
return `line-of-code-${n}`;
return `skeleton-line-${n}`;
},
},
};
......@@ -29,23 +35,42 @@ export default RepoLoadingFile;
</script>
<template>
<tr v-if="loading.tree && !hasFiles" class="loading-file">
<tr
v-if="showGhostLines"
class="loading-file">
<td>
<div class="animation-container animation-container-small">
<div v-for="n in 6" :class="lineOfCode(n)" :key="n"></div>
<div
class="animation-container animation-container-small">
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
</div>
</div>
</td>
<td v-if="!isMini" class="hidden-sm hidden-xs">
<td
v-if="!isMini"
class="hidden-sm hidden-xs">
<div class="animation-container">
<div v-for="n in 6" :class="lineOfCode(n)" :key="n"></div>
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
</div>
</div>
</td>
<td v-if="!isMini" class="hidden-xs">
<td
v-if="!isMini"
class="hidden-xs">
<div class="animation-container animation-container-small">
<div v-for="n in 6" :class="lineOfCode(n)" :key="n"></div>
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
</div>
</div>
</td>
</tr>
</tr>
</template>
<script>
import RepoMixin from '../mixins/repo_mixin';
const RepoPreviousDirectory = {
props: {
prevUrl: {
......@@ -7,6 +9,14 @@ const RepoPreviousDirectory = {
},
},
mixins: [RepoMixin],
computed: {
colSpanCondition() {
return this.isMini ? undefined : 3;
},
},
methods: {
linkClicked(file) {
this.$emit('linkclicked', file);
......@@ -19,8 +29,10 @@ export default RepoPreviousDirectory;
<template>
<tr class="prev-directory">
<td colspan="3">
<a :href="prevUrl" @click.prevent="linkClicked(prevUrl)">..</a>
<td
:colspan="colSpanCondition"
@click.prevent="linkClicked(prevUrl)">
<a :href="prevUrl">..</a>
</td>
</tr>
</template>
......@@ -4,7 +4,7 @@ import Store from '../stores/repo_store';
export default {
data: () => Store,
mounted() {
$(this.$el).find('.file-content').syntaxHighlight();
this.highlightFile();
},
computed: {
html() {
......@@ -12,10 +12,16 @@ export default {
},
},
methods: {
highlightFile() {
$(this.$el).find('.file-content').syntaxHighlight();
},
},
watch: {
html() {
this.$nextTick(() => {
$(this.$el).find('.file-content').syntaxHighlight();
this.highlightFile();
});
},
},
......@@ -24,9 +30,23 @@ export default {
<template>
<div>
<div v-if="!activeFile.render_error" v-html="activeFile.html"></div>
<div v-if="activeFile.render_error" class="vertical-center render-error">
<p class="text-center">The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead.</p>
<div
v-if="!activeFile.render_error"
v-html="activeFile.html">
</div>
<div
v-else-if="activeFile.tooLarge"
class="vertical-center render-error">
<p class="text-center">
The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead.
</p>
</div>
<div
v-else
class="vertical-center render-error">
<p class="text-center">
The source could not be displayed because a rendering error occured. You can <a :href="activeFile.raw_path">download</a> it instead.
</p>
</div>
</div>
</template>
......@@ -8,7 +8,7 @@ import RepoFile from './repo_file.vue';
import RepoLoadingFile from './repo_loading_file.vue';
import RepoMixin from '../mixins/repo_mixin';
const RepoSidebar = {
export default {
mixins: [RepoMixin],
components: {
'repo-file-options': RepoFileOptions,
......@@ -33,40 +33,36 @@ const RepoSidebar = {
});
},
linkClicked(clickedFile) {
let url = '';
fileClicked(clickedFile) {
let file = clickedFile;
if (typeof file === 'object') {
if (file.loading) return;
file.loading = true;
if (file.type === 'tree' && file.opened) {
file = Store.removeChildFilesOfTree(file);
file.loading = false;
} else {
url = file.url;
Service.url = url;
// I need to refactor this to do the `then` here.
// Not a callback. For now this is good enough.
// it works.
Helper.getContent(file, () => {
Service.url = file.url;
Helper.getContent(file)
.then(() => {
file.loading = false;
Helper.scrollTabsRight();
});
}
} else if (typeof file === 'string') {
// go back
url = file;
Service.url = url;
Helper.getContent(null, () => Helper.scrollTabsRight());
})
.catch(Helper.loadingError);
}
},
goToPreviousDirectoryClicked(prevURL) {
Service.url = prevURL;
Helper.getContent(null)
.then(() => Helper.scrollTabsRight())
.catch(Helper.loadingError);
},
},
};
export default RepoSidebar;
</script>
<template>
<div id="sidebar" :class="{'sidebar-mini' : isMini}" v-cloak>
<div id="sidebar" :class="{'sidebar-mini' : isMini}">
<table class="table">
<thead v-if="!isMini">
<tr>
......@@ -82,7 +78,7 @@ export default RepoSidebar;
<repo-previous-directory
v-if="isRoot"
:prev-url="prevURL"
@linkclicked="linkClicked(prevURL)"/>
@linkclicked="goToPreviousDirectoryClicked(prevURL)"/>
<repo-loading-file
v-for="n in 5"
:key="n"
......@@ -94,7 +90,7 @@ export default RepoSidebar;
:key="file.id"
:file="file"
:is-mini="isMini"
@linkclicked="linkClicked(file)"
@linkclicked="fileClicked(file)"
:is-tree="isTree"
:has-files="!!files.length"
:active-file="activeFile"/>
......
......@@ -10,10 +10,16 @@ const RepoTab = {
},
computed: {
closeLabel() {
if (this.tab.changed) {
return `${this.tab.name} changed`;
}
return `Close ${this.tab.name}`;
},
changedClass() {
const tabChangedObj = {
'fa-times': !this.tab.changed,
'fa-circle': this.tab.changed,
'fa-times close-icon': !this.tab.changed,
'fa-circle unsaved-icon': this.tab.changed,
};
return tabChangedObj;
},
......@@ -22,9 +28,9 @@ const RepoTab = {
methods: {
tabClicked: Store.setActiveFiles,
xClicked(file) {
closeTab(file) {
if (file.changed) return;
this.$emit('xclicked', file);
this.$emit('tabclosed', file);
},
},
};
......@@ -33,13 +39,25 @@ export default RepoTab;
</script>
<template>
<li>
<a href="#" class="close" @click.prevent="xClicked(tab)" v-if="!tab.loading">
<i class="fa" :class="changedClass"></i>
<li @click="tabClicked(tab)">
<a
href="#0"
class="close"
@click.stop.prevent="closeTab(tab)"
:aria-label="closeLabel">
<i
class="fa"
:class="changedClass"
aria-hidden="true">
</i>
</a>
<a href="#" class="repo-tab" v-if="!tab.loading" :title="tab.url" @click.prevent="tabClicked(tab)">{{tab.name}}</a>
<i v-if="tab.loading" class="fa fa-spinner fa-spin"></i>
<a
href="#"
class="repo-tab"
:title="tab.url"
@click.prevent="tabClicked(tab)">
{{tab.name}}
</a>
</li>
</template>
<script>
import Vue from 'vue';
import Store from '../stores/repo_store';
import RepoTab from './repo_tab.vue';
import RepoMixin from '../mixins/repo_mixin';
......@@ -14,30 +13,24 @@ const RepoTabs = {
data: () => Store,
methods: {
isOverflow() {
return this.$el.scrollWidth > this.$el.offsetWidth;
},
xClicked(file) {
tabClosed(file) {
Store.removeFromOpenedFiles(file);
},
},
watch: {
openedFiles() {
Vue.nextTick(() => {
this.tabsOverflow = this.isOverflow();
});
},
},
};
export default RepoTabs;
</script>
<template>
<ul id="tabs" v-if="isMini" v-cloak :class="{'overflown': tabsOverflow}">
<repo-tab v-for="tab in openedFiles" :key="tab.id" :tab="tab" :class="{'active' : tab.active}" @xclicked="xClicked"/>
<ul id="tabs">
<repo-tab
v-for="tab in openedFiles"
:key="tab.id"
:tab="tab"
:class="{'active' : tab.active}"
@tabclosed="tabClosed"
/>
<li class="tabs-divider" />
</ul>
</template>
/* global monaco */
import RepoEditor from '../components/repo_editor.vue';
import Store from '../stores/repo_store';
import Helper from '../helpers/repo_helper';
import monacoLoader from '../monaco_loader';
function repoEditorLoader() {
Store.monacoLoading = true;
return new Promise((resolve, reject) => {
monacoLoader(['vs/editor/editor.main'], () => {
Store.monaco = monaco;
Helper.monaco = monaco;
Store.monacoLoading = false;
resolve(RepoEditor);
}, reject);
}, () => {
Store.monacoLoading = false;
reject();
});
});
}
......
......@@ -4,6 +4,8 @@ import Store from '../stores/repo_store';
import '../../flash';
const RepoHelper = {
monacoInstance: null,
getDefaultActiveFile() {
return {
active: true,
......@@ -33,19 +35,23 @@ const RepoHelper = {
? window.performance
: Date,
getBranch() {
return $('button.dropdown-menu-toggle').attr('data-ref');
getFileExtension(fileName) {
return fileName.split('.').pop();
},
getLanguageIDForFile(file, langs) {
const ext = file.name.split('.').pop();
const ext = RepoHelper.getFileExtension(file.name);
const foundLang = RepoHelper.findLanguage(ext, langs);
return foundLang ? foundLang.id : 'plaintext';
},
getFilePathFromFullPath(fullPath, branch) {
return fullPath.split(`${Store.projectUrl}/blob/${branch}`)[1];
setMonacoModelFromLanguage() {
RepoHelper.monacoInstance.setModel(null);
const languages = RepoHelper.monaco.languages.getLanguages();
const languageID = RepoHelper.getLanguageIDForFile(Store.activeFile, languages);
const newModel = RepoHelper.monaco.editor.createModel(Store.blobRaw, languageID);
RepoHelper.monacoInstance.setModel(newModel);
},
findLanguage(ext, langs) {
......@@ -58,11 +64,11 @@ const RepoHelper = {
file.opened = true;
file.icon = 'fa-folder-open';
RepoHelper.toURL(file.url, file.name);
RepoHelper.updateHistoryEntry(file.url, file.name);
return file;
},
isKindaBinary() {
isRenderable() {
const okExts = ['md', 'svg'];
return okExts.indexOf(Store.activeFile.extension) > -1;
},
......@@ -76,22 +82,8 @@ const RepoHelper = {
.catch(RepoHelper.loadingError);
},
toggleFakeTab(loading, file) {
if (loading) return Store.addPlaceholderFile();
return Store.removeFromOpenedFiles(file);
},
setLoading(loading, file) {
if (Service.url.indexOf('blob') > -1) {
Store.loading.blob = loading;
return RepoHelper.toggleFakeTab(loading, file);
}
if (Service.url.indexOf('tree') > -1) Store.loading.tree = loading;
return undefined;
},
// when you open a directory you need to put the directory files under
// the directory... This will merge the list of the current directory and the new list.
getNewMergedList(inDirectory, currentList, newList) {
const newListSorted = newList.sort(this.compareFilesCaseInsensitive);
if (!inDirectory) return newListSorted;
......@@ -100,6 +92,9 @@ const RepoHelper = {
return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile);
},
// within the get new merged list this does the merging of the current list of files
// and the new list of files. The files are never "in" another directory they just
// appear like they are because of the margin.
mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) {
newList.reverse().forEach((newFile) => {
const fileIndex = indexOfFile + 1;
......@@ -135,21 +130,17 @@ const RepoHelper = {
return isRoot;
},
getContent(treeOrFile, cb) {
getContent(treeOrFile) {
let file = treeOrFile;
// const loadingData = RepoHelper.setLoading(true);
return Service.getContent()
.then((response) => {
const data = response.data;
// RepoHelper.setLoading(false, loadingData);
if (cb) cb();
Store.isTree = RepoHelper.isTree(data);
if (!Store.isTree) {
if (!file) file = data;
Store.binary = data.binary;
if (data.binary) {
Store.binaryMimeType = data.mime_type;
// file might be undefined
RepoHelper.setBinaryDataAsBase64(data);
Store.setViewToPreview();
......@@ -188,9 +179,8 @@ const RepoHelper = {
setFile(data, file) {
const newFile = data;
newFile.url = file.url || location.pathname;
newFile.url = file.url;
if (newFile.render_error === 'too_large') {
if (newFile.render_error === 'too_large' || newFile.render_error === 'collapsed') {
newFile.tooLarge = true;
}
newFile.newContent = '';
......@@ -199,10 +189,6 @@ const RepoHelper = {
Store.setActiveFiles(newFile);
},
toFA(icon) {
return `fa-${icon}`;
},
serializeBlob(blob) {
const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob);
simpleBlob.lastCommitMessage = blob.last_commit.message;
......@@ -226,7 +212,7 @@ const RepoHelper = {
type,
name,
url,
icon: RepoHelper.toFA(icon),
icon: `fa-${icon}`,
level: 0,
loading: false,
};
......@@ -244,42 +230,24 @@ const RepoHelper = {
setTimeout(() => {
const tabs = document.getElementById('tabs');
if (!tabs) return;
tabs.scrollLeft = 12000;
tabs.scrollLeft = tabs.scrollWidth;
}, 200);
},
dataToListOfFiles(data) {
const a = [];
// push in blobs
data.blobs.forEach((blob) => {
a.push(RepoHelper.serializeBlob(blob));
});
data.trees.forEach((tree) => {
a.push(RepoHelper.serializeTree(tree));
});
data.submodules.forEach((submodule) => {
a.push(RepoHelper.serializeSubmodule(submodule));
});
return a;
const { blobs, trees, submodules } = data;
return [
...blobs.map(blob => RepoHelper.serializeBlob(blob)),
...trees.map(tree => RepoHelper.serializeTree(tree)),
...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)),
];
},
genKey() {
return RepoHelper.Time.now().toFixed(3);
},
getStateKey() {
return RepoHelper.key;
},
setStateKey(key) {
RepoHelper.key = key;
},
toURL(url, title) {
updateHistoryEntry(url, title) {
const history = window.history;
RepoHelper.key = RepoHelper.genKey();
......@@ -296,7 +264,7 @@ const RepoHelper = {
},
loadingError() {
Flash('Unable to load the file at this time.');
Flash('Unable to load this content at this time.');
},
};
......
......@@ -7,8 +7,7 @@ import RepoEditButton from './components/repo_edit_button.vue';
import Translate from '../vue_shared/translate';
function initDropdowns() {
$('.project-refs-target-form').hide();
$('.fa-long-arrow-right').hide();
$('.js-tree-ref-target-holder').hide();
}
function addEventsForNonVueEls() {
......@@ -34,6 +33,8 @@ function setInitialStore(data) {
Store.projectId = data.projectId;
Store.projectName = data.projectName;
Store.projectUrl = data.projectUrl;
Store.canCommit = data.canCommit;
Store.onTopOfBranch = data.onTopOfBranch;
Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
Store.checkIsCommitable();
}
......@@ -44,6 +45,9 @@ function initRepo(el) {
components: {
repo: Repo,
},
render(createElement) {
return createElement('repo');
},
});
}
......
......@@ -2,6 +2,7 @@
import axios from 'axios';
import Store from '../stores/repo_store';
import Api from '../../api';
import Helper from '../helpers/repo_helper';
const RepoService = {
url: '',
......@@ -12,16 +13,9 @@ const RepoService = {
},
richExtensionRegExp: /md/,
checkCurrentBranchIsCommitable() {
const url = Store.service.refsUrl;
return axios.get(url, { params: {
ref: Store.currentBranch,
search: Store.currentBranch,
} });
},
getRaw(url) {
return axios.get(url, {
// Stop Axios from parsing a JSON file into a JS object
transformResponse: [res => res],
});
},
......@@ -36,7 +30,7 @@ const RepoService = {
},
urlIsRichBlob(url = this.url) {
const extension = url.split('.').pop();
const extension = Helper.getFileExtension(url);
return this.richExtensionRegExp.test(extension);
},
......@@ -73,7 +67,11 @@ const RepoService = {
commitFiles(payload, cb) {
Api.commitMultiple(Store.projectId, payload, (data) => {
if (data.short_id && data.stats) {
Flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
} else {
Flash(data.message);
}
cb();
});
},
......
......@@ -3,13 +3,11 @@ import Helper from '../helpers/repo_helper';
import Service from '../services/repo_service';
const RepoStore = {
ideEl: {},
monaco: {},
monacoLoading: false,
monacoInstance: {},
service: '',
editor: '',
sidebar: '',
canCommit: false,
onTopOfBranch: false,
editMode: false,
isTree: false,
isRoot: false,
......@@ -17,19 +15,10 @@ const RepoStore = {
projectId: '',
projectName: '',
projectUrl: '',
trees: [],
blobs: [],
submodules: [],
blobRaw: '',
blobRendered: '',
currentBlobView: 'repo-preview',
openedFiles: [],
tabSize: 100,
defaultTabSize: 100,
minTabSize: 30,
tabsOverflow: 41,
submitCommitsLoading: false,
binaryLoaded: false,
dialog: {
open: false,
title: '',
......@@ -45,9 +34,6 @@ const RepoStore = {
currentBranch: '',
targetBranch: 'new-branch',
commitMessage: '',
binaryMimeType: '',
// scroll bar space for windows
scrollWidth: 0,
binaryTypes: {
png: false,
md: false,
......@@ -58,7 +44,6 @@ const RepoStore = {
tree: false,
blob: false,
},
readOnly: true,
resetBinaryTypes() {
Object.keys(RepoStore.binaryTypes).forEach((key) => {
......@@ -68,14 +53,7 @@ const RepoStore = {
// mutations
checkIsCommitable() {
RepoStore.service.checkCurrentBranchIsCommitable()
.then((data) => {
// you shouldn't be able to make commits on commits or tags.
const { Branches, Commits, Tags } = data.data;
if (Branches && Branches.length) RepoStore.isCommitable = true;
if (Commits && Commits.length) RepoStore.isCommitable = false;
if (Tags && Tags.length) RepoStore.isCommitable = false;
}).catch(() => Flash('Failed to check if branch can be committed to.'));
RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
},
addFilesToDirectory(inDirectory, currentList, newList) {
......@@ -96,7 +74,6 @@ const RepoStore = {
if (file.binary) {
RepoStore.blobRaw = file.base64;
RepoStore.binaryMimeType = file.mime_type;
} else if (file.newContent || file.plain) {
RepoStore.blobRaw = file.newContent || file.plain;
} else {
......@@ -107,7 +84,7 @@ const RepoStore = {
}).catch(Helper.loadingError);
}
if (!file.loading) Helper.toURL(file.url, file.name);
if (!file.loading) Helper.updateHistoryEntry(file.url, file.name);
RepoStore.binary = file.binary;
},
......@@ -134,15 +111,15 @@ const RepoStore = {
removeChildFilesOfTree(tree) {
let foundTree = false;
const treeToClose = tree;
let wereDone = false;
let canStopSearching = false;
RepoStore.files = RepoStore.files.filter((file) => {
const isItTheTreeWeWant = file.url === treeToClose.url;
// if it's the next tree
if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) {
wereDone = true;
canStopSearching = true;
return true;
}
if (wereDone) return true;
if (canStopSearching) return true;
if (isItTheTreeWeWant) foundTree = true;
......@@ -159,8 +136,8 @@ const RepoStore = {
if (file.type === 'tree') return;
let foundIndex;
RepoStore.openedFiles = RepoStore.openedFiles.filter((openedFile, i) => {
if (openedFile.url === file.url) foundIndex = i;
return openedFile.url !== file.url;
if (openedFile.path === file.path) foundIndex = i;
return openedFile.path !== file.path;
});
// now activate the right tab based on what you closed.
......@@ -174,36 +151,16 @@ const RepoStore = {
return;
}
if (foundIndex) {
if (foundIndex > 0) {
if (foundIndex && foundIndex > 0) {
RepoStore.setActiveFiles(RepoStore.openedFiles[foundIndex - 1]);
}
}
},
addPlaceholderFile() {
const randomURL = Helper.Time.now();
const newFakeFile = {
active: false,
binary: true,
type: 'blob',
loading: true,
mime_type: 'loading',
name: 'loading',
url: randomURL,
fake: true,
};
RepoStore.openedFiles.push(newFakeFile);
return newFakeFile;
},
addToOpenedFiles(file) {
const openFile = file;
const openedFilesAlreadyExists = RepoStore.openedFiles
.some(openedFile => openedFile.url === openFile.url);
.some(openedFile => openedFile.path === openFile.path);
if (openedFilesAlreadyExists) return;
......@@ -238,4 +195,5 @@ const RepoStore = {
return RepoStore.currentBlobView === 'repo-preview';
},
};
export default RepoStore;
......@@ -71,7 +71,7 @@ export default {
/>
<div v-if="!isConfidential" class="no-value confidential-value">
<i class="fa fa-eye is-not-confidential"></i>
None
This issue is not confidential
</div>
<div v-else class="value confidential-value hide-collapsed">
<i aria-hidden="true" data-hidden="true" class="fa fa-eye-slash is-confidential"></i>
......
<script>
const PopupDialog = {
export default {
name: 'popup-dialog',
props: {
open: Boolean,
title: String,
body: String,
title: {
type: String,
required: true,
},
body: {
type: String,
required: true,
},
kind: {
type: String,
required: false,
default: 'primary',
},
closeButtonLabel: {
type: String,
required: false,
default: 'Cancel',
},
primaryButtonLabel: {
type: String,
default: 'Save changes',
required: true,
},
},
computed: {
typeOfClass() {
const className = `btn-${this.kind}`;
const returnObj = {};
returnObj[className] = true;
return returnObj;
btnKindClass() {
return {
[`btn-${this.kind}`]: true,
};
},
},
......@@ -33,33 +39,45 @@ const PopupDialog = {
close() {
this.$emit('toggle', false);
},
yesClick() {
this.$emit('submit', true);
},
noClick() {
this.$emit('submit', false);
emitSubmit(status) {
this.$emit('submit', status);
},
},
};
export default PopupDialog;
</script>
<template>
<div class="modal popup-dialog" tabindex="-1" v-show="open" role="dialog">
<div
class="modal popup-dialog"
role="dialog"
tabindex="-1">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" @click="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<button type="button"
class="close"
@click="close"
aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">{{this.title}}</h4>
</div>
<div class="modal-body">
<p>{{this.body}}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal" @click="noClick">{{closeButtonLabel}}</button>
<button type="button" class="btn" :class="typeOfClass" @click="yesClick">{{primaryButtonLabel}}</button>
<button
type="button"
class="btn btn-default"
@click="emitSubmit(false)">
{{closeButtonLabel}}
</button>
<button type="button"
class="btn"
:class="btnKindClass"
@click="emitSubmit(true)">
{{primaryButtonLabel}}
</button>
</div>
</div>
</div>
......
......@@ -187,3 +187,81 @@ a {
.fade-in-full {
animation: fadeInFull $fade-in-duration 1;
}
.animation-container {
background: $repo-editor-grey;
height: 40px;
overflow: hidden;
position: relative;
&.animation-container-small {
height: 12px;
}
&::before {
animation-duration: 1s;
animation-fill-mode: forwards;
animation-iteration-count: infinite;
animation-name: blockTextShine;
animation-timing-function: linear;
background-image: $repo-editor-linear-gradient;
background-repeat: no-repeat;
background-size: 800px 45px;
content: ' ';
display: block;
height: 100%;
position: relative;
}
div {
background: $white-light;
height: 6px;
left: 0;
position: absolute;
right: 0;
}
.skeleton-line-1 {
left: 0;
top: 8px;
}
.skeleton-line-2 {
left: 150px;
top: 0;
height: 10px;
}
.skeleton-line-3 {
left: 0;
top: 23px;
}
.skeleton-line-4 {
left: 0;
top: 38px;
}
.skeleton-line-5 {
left: 200px;
top: 28px;
height: 10px;
}
.skeleton-line-6 {
top: 14px;
left: 230px;
height: 10px;
}
}
@keyframes blockTextShine {
0% {
transform: translateX(-468px);
}
100% {
transform: translateX(468px);
}
}
......@@ -117,10 +117,6 @@ body {
margin-top: $header-height + $performance-bar-height;
}
[v-cloak] {
display: none;
}
.vertical-center {
min-height: 100vh;
display: flex;
......
......@@ -403,6 +403,7 @@ header.navbar-gitlab-new {
}
.breadcrumbs-extra {
display: flex;
flex: 0 0 auto;
margin-left: auto;
}
......
......@@ -250,32 +250,13 @@ $new-sidebar-collapsed-width: 50px;
position: absolute;
top: -30px;
bottom: -30px;
left: 0;
left: -10px;
right: -30px;
z-index: -1;
}
&::after {
content: "";
position: absolute;
top: 44px;
left: -30px;
right: 35px;
bottom: 0;
height: 100%;
max-height: 150px;
z-index: -1;
transform: skew(33deg);
}
&.is-above {
margin-top: 1px;
&::after {
top: auto;
bottom: 44px;
transform: skew(-30deg);
}
}
> .active {
......@@ -322,8 +303,7 @@ $new-sidebar-collapsed-width: 50px;
}
}
&:not(.active):hover > a,
> a:hover,
&.active > a:hover,
&.is-over > a {
background-color: $white-light;
}
......
......@@ -286,6 +286,10 @@
.gpg-status-box {
&:empty {
display: none;
}
&.valid {
@include green-status-color;
}
......
......@@ -560,9 +560,13 @@
}
.diff-files-changed {
.inline-parallel-buttons {
position: relative;
z-index: 1;
}
.commit-stat-summary {
@include new-style-dropdown;
z-index: -1;
@media (min-width: $screen-sm-min) {
margin-left: -$gl-padding;
......
......@@ -8,13 +8,13 @@
.is-confidential {
color: $orange-600;
background-color: $orange-50;
border-radius: 3px;
border-radius: $border-radius-default;
padding: 5px;
margin: 0 3px 0 -4px;
}
.is-not-confidential {
border-radius: 3px;
border-radius: $border-radius-default;
padding: 5px;
margin: 0 3px 0 -4px;
}
......
......@@ -453,7 +453,10 @@ ul.notes {
}
.note-actions {
align-self: flex-start;
flex-shrink: 0;
display: inline-flex;
align-items: center;
// For PhantomJS that does not support flex
float: right;
margin-left: 10px;
......@@ -463,18 +466,12 @@ ul.notes {
float: none;
margin-left: 0;
}
.note-action-button {
margin-left: 8px;
}
.more-actions-toggle {
margin-left: 2px;
}
}
.more-actions {
display: inline-block;
float: right; // phantomjs fallback
display: flex;
align-items: flex-end;
.tooltip {
white-space: nowrap;
......@@ -482,16 +479,10 @@ ul.notes {
}
.more-actions-toggle {
padding: 0;
&:hover .icon,
&:focus .icon {
color: $blue-600;
}
.icon {
padding: 0 6px;
}
}
.more-actions-dropdown {
......@@ -519,28 +510,42 @@ ul.notes {
@include notes-media('max', $screen-md-max) {
float: none;
margin-left: 0;
.note-action-button {
margin-left: 0;
}
}
.note-actions-item {
margin-left: 15px;
display: flex;
align-items: center;
&.more-actions {
// compensate for narrow icon
margin-left: 10px;
}
}
.note-action-button {
display: inline;
line-height: 20px;
line-height: 1;
padding: 0;
min-width: 16px;
color: $gray-darkest;
.fa {
color: $gray-darkest;
position: relative;
font-size: 17px;
font-size: 16px;
}
svg {
height: 16px;
width: 16px;
fill: $gray-darkest;
top: 0;
vertical-align: text-top;
path {
fill: currentColor;
}
}
.award-control-icon-positive,
......@@ -613,10 +618,7 @@ ul.notes {
.note-role {
position: relative;
top: -2px;
display: inline-block;
padding-left: 7px;
padding-right: 7px;
padding: 0 7px;
color: $notes-role-color;
font-size: 12px;
line-height: 20px;
......
......@@ -824,6 +824,7 @@ button.mini-pipeline-graph-dropdown-toggle {
* Top arrow in the dropdown in the mini pipeline graph
*/
.mini-pipeline-graph-dropdown-menu {
z-index: 200;
&::before,
&::after {
......
.fade-enter-active,
.fade-leave-active {
transition: opacity .5s;
transition: opacity $sidebar-transition-duration;
}
.monaco-loader {
......@@ -28,11 +28,6 @@
.project-refs-form,
.project-refs-target-form {
display: inline-block;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
.fade-enter,
......@@ -90,7 +85,7 @@
}
.blob-viewer-container {
height: calc(100vh - 63px);
height: calc(100vh - 62px);
overflow: auto;
}
......@@ -114,6 +109,7 @@
border-right: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
white-space: nowrap;
cursor: pointer;
&.remove {
animation: swipeRightDissapear ease-in 0.1s;
......@@ -133,10 +129,10 @@
a {
@include str-truncated(100px);
color: $black;
display: inline-block;
width: 100px;
text-align: center;
vertical-align: middle;
text-decoration: none;
&.close {
width: auto;
......@@ -146,15 +142,15 @@
}
}
i.fa.fa-times,
i.fa.fa-circle {
.close-icon,
.unsaved-icon {
float: right;
margin-top: 3px;
margin-left: 15px;
color: $gray-darkest;
}
i.fa.fa-circle {
.unsaved-icon {
color: $brand-success;
}
......@@ -204,7 +200,7 @@
background: $gray-light;
padding: 20px;
span.help-block {
.help-block {
padding-top: 7px;
margin-top: 0;
}
......@@ -232,6 +228,7 @@
vertical-align: top;
width: 20%;
border-right: 1px solid $white-normal;
min-height: 475px;
height: calc(100vh + 20px);
overflow: auto;
}
......@@ -261,7 +258,6 @@
text-transform: uppercase;
font-weight: bold;
color: $gray-darkest;
width: 185px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
......@@ -270,7 +266,7 @@
}
}
.fa {
.file-icon {
margin-right: 5px;
}
......@@ -280,118 +276,22 @@
}
a {
@include str-truncated(250px);
color: $almost-black;
display: inline-block;
vertical-align: middle;
}
ul {
list-style-type: none;
padding: 0;
li {
border-bottom: 1px solid $border-gray-normal;
padding: 10px 20px;
a {
color: $almost-black;
}
.fa {
font-size: $code_font_size;
margin-right: 5px;
}
}
}
}
}
.animation-container {
background: $repo-editor-grey;
height: 40px;
overflow: hidden;
position: relative;
&.animation-container-small {
height: 12px;
}
&::before {
animation-duration: 1s;
animation-fill-mode: forwards;
animation-iteration-count: infinite;
animation-name: blockTextShine;
animation-timing-function: linear;
background-image: $repo-editor-linear-gradient;
background-repeat: no-repeat;
background-size: 800px 45px;
content: ' ';
display: block;
height: 100%;
position: relative;
}
div {
background: $white-light;
height: 6px;
left: 0;
position: absolute;
right: 0;
}
.line-of-code-1 {
left: 0;
top: 8px;
}
.line-of-code-2 {
left: 150px;
top: 0;
height: 10px;
}
.line-of-code-3 {
left: 0;
top: 23px;
}
.line-of-code-4 {
left: 0;
top: 38px;
}
.line-of-code-5 {
left: 200px;
top: 28px;
height: 10px;
}
.line-of-code-6 {
top: 14px;
left: 230px;
height: 10px;
}
}
.render-error {
min-height: calc(100vh - 63px);
min-height: calc(100vh - 62px);
p {
width: 100%;
}
}
@keyframes blockTextShine {
0% {
transform: translateX(-468px);
}
100% {
transform: translateX(468px);
}
}
@keyframes swipeRightAppear {
0% {
transform: scaleX(0.00);
......
......@@ -29,6 +29,10 @@
margin-right: 15px;
}
.tree-ref-target-holder {
display: inline-block;
}
.repo-breadcrumb {
li:last-of-type {
position: relative;
......
......@@ -6,6 +6,13 @@ module CycleAnalyticsParams
end
def start_date(params)
params[:start_date] == '30' ? 30.days.ago : 90.days.ago
case params[:start_date]
when '7'
7.days.ago
when '30'
30.days.ago
else
90.days.ago
end
end
end
......@@ -6,7 +6,7 @@ class Explore::ProjectsController < Explore::ApplicationController
def index
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
@projects = load_projects.page(params[:page])
@projects = load_projects
respond_to do |format|
format.html
......@@ -21,7 +21,7 @@ class Explore::ProjectsController < Explore::ApplicationController
def trending
params[:trending] = true
@sort = params[:sort]
@projects = load_projects.page(params[:page])
@projects = load_projects
respond_to do |format|
format.html
......@@ -34,7 +34,7 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def starred
@projects = load_projects.reorder('star_count DESC').page(params[:page])
@projects = load_projects.reorder('star_count DESC')
respond_to do |format|
format.html
......@@ -50,6 +50,9 @@ class Explore::ProjectsController < Explore::ApplicationController
def load_projects
ProjectsFinder.new(current_user: current_user, params: params)
.execute.includes(:route, namespace: :route)
.execute
.includes(:route, namespace: :route)
.page(params[:page])
.without_count
end
end
......@@ -198,6 +198,10 @@ class Projects::BlobController < Projects::ApplicationController
json = blob_json(@blob)
return render_404 unless json
path_segments = @path.split('/')
path_segments.pop
tree_path = path_segments.join('/')
render json: json.merge(
path: blob.path,
name: blob.name,
......@@ -212,6 +216,7 @@ class Projects::BlobController < Projects::ApplicationController
raw_path: project_raw_path(project, @id),
blame_path: project_blame_path(project, @id),
commits_path: project_commits_path(project, @id),
tree_path: project_tree_path(project, File.join(@ref, tree_path)),
permalink: project_blob_path(project, File.join(@commit.id, @path))
)
end
......
......@@ -212,7 +212,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
def create_merge_request
result = MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute
result = ::MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute
if result[:status] == :success
render json: MergeRequestCreateSerializer.new.represent(result[:merge_request])
......
......@@ -59,7 +59,7 @@ module GroupsHelper
end
def remove_group_message(group)
_("You are going to remove %{group_name}.\nRemoved groups CANNOT be restored!\nAre you ABSOLUTELY sure?") %
_("You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") %
{ group_name: group.name }
end
......
module PaginationHelper
def paginate_collection(collection, remote: nil)
if collection.is_a?(Kaminari::PaginatableWithoutCount)
paginate_without_count(collection)
elsif collection.respond_to?(:total_pages)
paginate_with_count(collection, remote: remote)
end
end
def paginate_without_count(collection)
render(
'kaminari/gitlab/without_count',
previous_path: path_to_prev_page(collection),
next_path: path_to_next_page(collection)
)
end
def paginate_with_count(collection, remote: nil)
paginate(collection, remote: remote, theme: 'gitlab')
end
end
......@@ -80,7 +80,7 @@ module ProjectsHelper
end
def remove_project_message(project)
_("You are going to remove %{project_name_with_namespace}.\nRemoved project CANNOT be restored!\nAre you ABSOLUTELY sure?") %
_("You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?") %
{ project_name_with_namespace: project.name_with_namespace }
end
......@@ -234,6 +234,8 @@ module ProjectsHelper
# If no limit is applied we'll just issue a COUNT since the result set could
# be too large to load into memory.
def any_projects?(projects)
return projects.any? if projects.is_a?(Array)
if projects.limit_value
projects.to_a.any?
else
......
......@@ -11,11 +11,11 @@ module Emails
@member_source_type = member_source_type
@member_id = member_id
admins = member_source.members.owners_and_masters.includes(:user).pluck(:notification_email)
admins = member_source.members.owners_and_masters.pluck(:notification_email)
# A project in a group can have no explicit owners/masters, in that case
# we fallbacks to the group's owners/masters.
if admins.empty? && member_source.respond_to?(:group) && member_source.group
admins = member_source.group.members.owners_and_masters.includes(:user).pluck(:notification_email)
admins = member_source.group.members.owners_and_masters.pluck(:notification_email)
end
mail(to: admins,
......
......@@ -212,21 +212,39 @@ class Group < Namespace
end
def user_ids_for_project_authorizations
users_with_parents.pluck(:id)
members_with_parents.pluck(:user_id)
end
def members_with_parents
GroupMember.active.where(source_id: ancestors.pluck(:id).push(id)).where.not(user_id: nil)
# Avoids an unnecessary SELECT when the group has no parents
source_ids =
if parent_id
self_and_ancestors.reorder(nil).select(:id)
else
id
end
GroupMember
.active_without_invites
.where(source_id: source_ids)
end
def members_with_descendants
GroupMember
.active_without_invites
.where(source_id: self_and_descendants.reorder(nil).select(:id))
end
def users_with_parents
User.where(id: members_with_parents.select(:user_id))
User
.where(id: members_with_parents.select(:user_id))
.reorder(nil)
end
def users_with_descendants
members_with_descendants = GroupMember.non_request.where(source_id: descendants.pluck(:id).push(id))
User.where(id: members_with_descendants.select(:user_id))
User
.where(id: members_with_descendants.select(:user_id))
.reorder(nil)
end
def max_member_access_for_user(user)
......
......@@ -41,9 +41,20 @@ class Member < ActiveRecord::Base
is_external_invite = arel_table[:user_id].eq(nil).and(arel_table[:invite_token].not_eq(nil))
user_is_active = User.arel_table[:state].eq(:active)
includes(:user).references(:users)
.where(is_external_invite.or(user_is_active))
user_ok = Arel::Nodes::Grouping.new(is_external_invite).or(user_is_active)
left_join_users
.where(user_ok)
.where(requested_at: nil)
.reorder(nil)
end
# Like active, but without invites. For when a User is required.
scope :active_without_invites, -> do
left_join_users
.where(users: { state: 'active' })
.where(requested_at: nil)
.reorder(nil)
end
scope :invite, -> { where.not(invite_token: nil) }
......@@ -276,6 +287,13 @@ class Member < ActiveRecord::Base
@notification_setting ||= user.notification_settings_for(source)
end
def notifiable?(type, opts = {})
# always notify when there isn't a user yet
return true if user.blank?
NotificationRecipientService.notifiable?(user, type, notifiable_options.merge(opts))
end
private
def send_invite
......@@ -332,4 +350,8 @@ class Member < ActiveRecord::Base
def notification_service
NotificationService.new
end
def notifiable_options
{}
end
end
......@@ -30,6 +30,10 @@ class GroupMember < Member
'Group'
end
def notifiable_options
{ group: group }
end
private
def send_invite
......
......@@ -87,6 +87,10 @@ class ProjectMember < Member
project.owner == user
end
def notifiable_options
{ project: project }
end
private
def delete_member_todos
......
......@@ -443,7 +443,8 @@ class MergeRequest < ActiveRecord::Base
end
def reload_diff_if_branch_changed
if source_branch_changed? || target_branch_changed?
if (source_branch_changed? || target_branch_changed?) &&
(source_branch_head && target_branch_head)
reload_diff
end
end
......@@ -792,11 +793,7 @@ class MergeRequest < ActiveRecord::Base
end
def fetch_ref
target_project.repository.fetch_ref(
source_project.repository.path_to_repo,
"refs/heads/#{source_branch}",
ref_path
)
write_ref
update_column(:ref_fetched, true)
end
......@@ -939,4 +936,17 @@ class MergeRequest < ActiveRecord::Base
true
end
private
def write_ref
target_project.repository.with_repo_branch_commit(
source_project.repository, source_branch) do |commit|
if commit
target_project.repository.write_ref(ref_path, commit.sha)
else
raise Rugged::ReferenceError, 'source repository is empty'
end
end
end
end
......@@ -156,6 +156,14 @@ class Namespace < ActiveRecord::Base
.base_and_ancestors
end
def self_and_ancestors
return self.class.where(id: id) unless parent_id
Gitlab::GroupHierarchy
.new(self.class.where(id: id))
.base_and_ancestors
end
# Returns all the descendants of the current namespace.
def descendants
Gitlab::GroupHierarchy
......@@ -163,6 +171,12 @@ class Namespace < ActiveRecord::Base
.base_and_descendants
end
def self_and_descendants
Gitlab::GroupHierarchy
.new(self.class.where(id: id))
.base_and_descendants
end
def user_ids_for_project_authorizations
[owner_id]
end
......
......@@ -5,14 +5,22 @@ class NotificationRecipient
custom_action: nil,
target: nil,
acting_user: nil,
project: nil
project: nil,
group: nil,
skip_read_ability: false
)
unless NotificationSetting.levels.key?(type) || type == :subscription
raise ArgumentError, "invalid type: #{type.inspect}"
end
@custom_action = custom_action
@acting_user = acting_user
@target = target
@project = project || @target&.project
@project = project || default_project
@group = group || @project&.group
@user = user
@type = type
@skip_read_ability = skip_read_ability
end
def notification_setting
......@@ -77,6 +85,8 @@ class NotificationRecipient
def has_access?
DeclarativePolicy.subject_scope do
return false unless user.can?(:receive_notifications)
return true if @skip_read_ability
return false if @project && !user.can?(:read_project, @project)
return true unless read_ability
......@@ -96,6 +106,7 @@ class NotificationRecipient
private
def read_ability
return nil if @skip_read_ability
return @read_ability if instance_variable_defined?(:@read_ability)
@read_ability =
......@@ -111,12 +122,18 @@ class NotificationRecipient
end
end
def default_project
return nil if @target.nil?
return @target if @target.is_a?(Project)
return @target.project if @target.respond_to?(:project)
end
def find_notification_setting
project_setting = @project && user.notification_settings_for(@project)
return project_setting unless project_setting.nil? || project_setting.global?
group_setting = @project&.group && user.notification_settings_for(@project.group)
group_setting = @group && user.notification_settings_for(@group)
return group_setting unless group_setting.nil? || group_setting.global?
......
......@@ -196,7 +196,6 @@ class Project < ActiveRecord::Base
accepts_nested_attributes_for :import_data
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :count, to: :forks, prefix: true
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team
......@@ -1048,9 +1047,7 @@ class Project < ActiveRecord::Base
def change_head(branch)
if repository.branch_exists?(branch)
repository.before_change_head
repository.rugged.references.create('HEAD',
"refs/heads/#{branch}",
force: true)
repository.write_ref('HEAD', "refs/heads/#{branch}")
repository.copy_gitattributes(branch)
repository.after_change_head
reload_default_branch
......@@ -1398,6 +1395,10 @@ class Project < ActiveRecord::Base
# @deprecated cannot remove yet because it has an index with its name in elasticsearch
alias_method :path_with_namespace, :full_path
def forks_count
Projects::ForksCountService.new(self).count
end
private
def cross_namespace_reference?(from)
......
......@@ -224,7 +224,7 @@ class Repository
# This will still fail if the file is corrupted (e.g. 0 bytes)
begin
rugged.references.create(keep_around_ref_name(sha), sha, force: true)
write_ref(keep_around_ref_name(sha), sha)
rescue Rugged::ReferenceError => ex
Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}"
rescue Rugged::OSError => ex
......@@ -237,6 +237,10 @@ class Repository
ref_exists?(keep_around_ref_name(sha))
end
def write_ref(ref_path, sha)
rugged.references.create(ref_path, sha, force: true)
end
def diverging_commit_counts(branch)
root_ref_hash = raw_repository.rev_parse_target(root_ref).oid
cache.fetch(:"diverging_commit_counts_#{branch.name}") do
......@@ -985,12 +989,10 @@ class Repository
if start_repository == self
start_branch_name
else
tmp_ref = "refs/tmp/#{SecureRandom.hex}/head"
fetch_ref(
tmp_ref = fetch_ref(
start_repository.path_to_repo,
"#{Gitlab::Git::BRANCH_REF_PREFIX}#{start_branch_name}",
tmp_ref
"refs/tmp/#{SecureRandom.hex}/head"
)
start_repository.commit(start_branch_name).sha
......@@ -1021,7 +1023,12 @@ class Repository
def fetch_ref(source_path, source_ref, target_ref)
args = %W(fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref})
run_git(args)
message, status = run_git(args)
# Make sure ref was created, and raise Rugged::ReferenceError when not
raise Rugged::ReferenceError, message if status != 0
target_ref
end
def create_ref(ref, ref_path)
......
......@@ -726,9 +726,9 @@ class User < ActiveRecord::Base
end
def sanitize_attrs
%w[username skype linkedin twitter].each do |attr|
value = public_send(attr) # rubocop:disable GitlabSecurity/PublicSend
public_send("#{attr}=", Sanitize.clean(value)) if value.present? # rubocop:disable GitlabSecurity/PublicSend
%i[skype linkedin twitter].each do |attr|
value = self[attr]
self[attr] = Sanitize.clean(value) if value.present?
end
end
......@@ -1069,6 +1069,7 @@ class User < ActiveRecord::Base
# Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
def send_devise_notification(notification, *args)
return true unless can?(:receive_notifications)
devise_mailer.send(notification, self, *args).deliver_later
end
......
# TODO: Inherit from TreeEntity, when `Tree` implements `id` and `name` like `Gitlab::Git::Tree`.
class TreeRootEntity < Grape::Entity
include RequestAwareEntity
expose :path
expose :trees, using: TreeEntity
expose :blobs, using: BlobEntity
expose :submodules, using: SubmoduleEntity
expose :parent_tree_url do |tree|
path = tree.path.sub(%r{\A/}, '')
next unless path.present?
path_segments = path.split('/')
path_segments.pop
parent_tree_path = path_segments.join('/')
project_tree_path(request.project, File.join(request.ref, parent_tree_path))
end
end
......@@ -10,9 +10,11 @@ class NotificationService
# only if ssh key is not deploy key
#
# This is security email so it will be sent
# even if user disabled notifications
# even if user disabled notifications. However,
# it won't be sent to internal users like the
# ghost user or the EE support bot.
def new_key(key)
if key.user
if key.user&.can?(:receive_notifications)
mailer.new_ssh_key_email(key.id).deliver_later
end
end
......@@ -22,14 +24,14 @@ class NotificationService
# This is a security email so it will be sent even if the user user disabled
# notifications
def new_gpg_key(gpg_key)
if gpg_key.user
if gpg_key.user&.can?(:receive_notifications)
mailer.new_gpg_key_email(gpg_key.id).deliver_later
end
end
# Always notify user about email added to profile
def new_email(email)
if email.user
if email.user&.can?(:receive_notifications)
mailer.new_email_email(email.id).deliver_later
end
end
......@@ -185,6 +187,8 @@ class NotificationService
# Notify new user with email after creation
def new_user(user, token = nil)
return true unless notifiable?(user, :mention)
# Don't email omniauth created users
mailer.new_user_email(user.id, token).deliver_later unless user.identities.any?
end
......@@ -206,19 +210,27 @@ class NotificationService
# Members
def new_access_request(member)
return true unless member.notifiable?(:subscription)
mailer.member_access_requested_email(member.real_source_type, member.id).deliver_later
end
def decline_access_request(member)
return true unless member.notifiable?(:subscription)
mailer.member_access_denied_email(member.real_source_type, member.source_id, member.user_id).deliver_later
end
# Project invite
def invite_project_member(project_member, token)
return true unless project_member.notifiable?(:subscription)
mailer.member_invited_email(project_member.real_source_type, project_member.id, token).deliver_later
end
def accept_project_invite(project_member)
return true unless project_member.notifiable?(:subscription)
mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later
end
......@@ -232,10 +244,14 @@ class NotificationService
end
def new_project_member(project_member)
return true unless project_member.notifiable?(:mention, skip_read_ability: true)
mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
end
def update_project_member(project_member)
return true unless project_member.notifiable?(:mention)
mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
end
......@@ -249,6 +265,9 @@ class NotificationService
end
def decline_group_invite(group_member)
# always send this one, since it's a response to the user's own
# action
mailer.member_invite_declined_email(
group_member.real_source_type,
group_member.group.id,
......@@ -258,15 +277,19 @@ class NotificationService
end
def new_group_member(group_member)
return true unless group_member.notifiable?(:mention)
mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
end
def update_group_member(group_member)
return true unless group_member.notifiable?(:mention)
mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
end
def project_was_moved(project, old_path_with_namespace)
recipients = NotificationRecipientService.notifiable_users(project.team.members, :mention, project: project)
recipients = notifiable_users(project.team.members, :mention, project: project)
recipients.each do |recipient|
mailer.project_was_moved_email(
......@@ -288,10 +311,14 @@ class NotificationService
end
def project_exported(project, current_user)
return true unless notifiable?(current_user, :mention, project: project)
mailer.project_was_exported_email(current_user, project).deliver_later
end
def project_not_exported(project, current_user, errors)
return true unless notifiable?(current_user, :mention, project: project)
mailer.project_was_not_exported_email(current_user, project, errors).deliver_later
end
......@@ -300,7 +327,7 @@ class NotificationService
return unless mailer.respond_to?(email_template)
recipients ||= NotificationRecipientService.notifiable_users(
recipients ||= notifiable_users(
[pipeline.user], :watch,
custom_action: :"#{pipeline.status}_pipeline",
target: pipeline
......@@ -369,7 +396,7 @@ class NotificationService
def relabeled_resource_email(target, labels, current_user, method)
recipients = labels.flat_map { |l| l.subscribers(target.project) }
recipients = NotificationRecipientService.notifiable_users(
recipients = notifiable_users(
recipients, :subscription,
target: target,
acting_user: current_user
......@@ -401,4 +428,14 @@ class NotificationService
object.previous_changes[attribute].first
end
end
private
def notifiable?(*args)
NotificationRecipientService.notifiable?(*args)
end
def notifiable_users(*args)
NotificationRecipientService.notifiable_users(*args)
end
end
......@@ -128,6 +128,8 @@ module Projects
project.repository.before_delete
Repository.new(wiki_path, project, disk_path: repo_path).before_delete
Projects::ForksCountService.new(project).delete_cache
end
end
end
......@@ -21,11 +21,17 @@ module Projects
builds_access_level = @project.project_feature.builds_access_level
new_project.project_feature.update_attributes(builds_access_level: builds_access_level)
refresh_forks_count
new_project
end
private
def refresh_forks_count
Projects::ForksCountService.new(@project).refresh_cache
end
def allowed_visibility_level
project_level = @project.visibility_level
......
module Projects
# Service class for getting and caching the number of forks of a project.
class ForksCountService
def initialize(project)
@project = project
end
def count
Rails.cache.fetch(cache_key) { uncached_count }
end
def refresh_cache
Rails.cache.write(cache_key, uncached_count)
end
def delete_cache
Rails.cache.delete(cache_key)
end
private
def uncached_count
@project.forks.count
end
def cache_key
['projects', @project.id, 'forks_count']
end
end
end
......@@ -13,7 +13,13 @@ module Projects
::MergeRequests::CloseService.new(@project, @current_user).execute(mr)
end
refresh_forks_count(@project.forked_from_project)
@project.forked_project_link.destroy
end
def refresh_forks_count(project)
Projects::ForksCountService.new(project).refresh_cache
end
end
end
......@@ -4,7 +4,7 @@ class PersonalFileUploader < FileUploader
end
def self.base_dir
File.join(root_dir, 'system')
File.join(root_dir, '-', 'system')
end
private
......
.gl-pagination
%ul.pagination.clearfix
- if previous_path
%li.prev
= link_to(t('views.pagination.previous'), previous_path, rel: 'prev')
- if next_path
%li.next
= link_to(t('views.pagination.next'), next_path, rel: 'next')
- if commit.has_signature?
%button{ class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'auto top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } }
%i.fa.fa-spinner.fa-spin
......@@ -39,6 +39,9 @@
%span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-menu-align-right
%li
%a{ "href" => "#", "data-value" => "7" }
{{ n__('Last %d day', 'Last %d days', 7) }}
%li
%a{ "href" => "#", "data-value" => "30" }
{{ n__('Last %d day', 'Last %d days', 30) }}
......
......@@ -6,7 +6,7 @@
.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed{ class: ("diff-files-changed-merge-request" if merge_request) }
.files-changed-inner
.inline-parallel-buttons
.inline-parallel-buttons.hidden-xs.hidden-sm
- if !diffs_expanded? && diff_files.any? { |diff_file| diff_file.collapsed? }
= link_to 'Expand all', url_for(params.merge(expanded: 1, format: nil)), class: 'btn btn-default'
- if show_whitespace_toggle
......
......@@ -10,7 +10,7 @@
%strong.cgreen #{sum_added_lines} additions
and
%strong.cred #{sum_removed_lines} deletions
.diff-stats-additions-deletions-collapsed.pull-right{ "aria-hidden": "true", "aria-describedby": "diff-stats" }
.diff-stats-additions-deletions-collapsed.pull-right.hidden-xs.hidden-sm{ "aria-hidden": "true", "aria-describedby": "diff-stats" }
%strong.cgreen<
+#{sum_added_lines}
%strong.cred<
......
= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe' do
= icon('rss')
- if @can_bulk_update
= button_tag "Edit Issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
= button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
= link_to "New issue", new_project_issue_path(@project,
issue: { assignee_id: issues_finder.assignee.try(:id),
milestone_id: issues_finder.milestones.first.try(:id) }),
......
- if @can_bulk_update
= button_tag "Edit Merge Requests", class: "btn js-bulk-update-toggle"
= button_tag "Edit merge requests", class: "btn append-right-10 js-bulk-update-toggle"
- if merge_project
= link_to new_merge_request_path, class: "btn btn-new", title: "New merge request" do
New merge request
......@@ -17,6 +17,7 @@
"inline-template" => true,
"ref" => "note_#{note.id}" }
.note-actions-item
%button.note-action-button.line-resolve-btn{ type: "button",
class: ("is-disabled" unless can_resolve),
":class" => "{ 'is-active': isResolved }",
......@@ -31,10 +32,17 @@
- if current_user
- if note.emoji_awardable?
- user_authored = note.user_authored?(current_user)
= link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
.note-actions-item
= button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do
= icon('spinner spin')
%span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
%span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
%span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
- if note_editable
.note-actions-item
= button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
%span.link-highlight
= custom_icon('icon_pencil')
= render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
- is_current_user = current_user == note.author
- if note_editable || !is_current_user
.dropdown.more-actions
.dropdown.more-actions.note-actions-item
= button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn btn-transparent', data: { toggle: 'dropdown', container: 'body' } do
= icon('ellipsis-v', class: 'icon')
%span.icon
= custom_icon('ellipsis_v')
%ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
- if note_editable
%li
= button_tag 'Edit comment', class: 'js-note-edit btn btn-transparent'
%li.divider
- unless is_current_user
%li
= link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do
......
......@@ -2,6 +2,7 @@
.tree-ref-holder
= render 'shared/ref_switcher', destination: 'tree', path: @path
- if show_new_repo?
.tree-ref-target-holder.js-tree-ref-target-holder
= icon('long-arrow-right', title: 'to target branch')
= render 'shared/target_switcher', destination: 'tree', path: @path
......
......@@ -6,7 +6,7 @@
- @options && @options.each do |key, value|
= hidden_field_tag key, value, id: nil
.dropdown
= dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown" }
= dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project), field_name: 'ref', submit_form_on_click: true, visit: true }, { toggle_class: "js-project-refs-dropdown" }
.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
= dropdown_title _("Switch branch/tag")
= dropdown_filter _("Search branches and tags")
......
- dropdown_toggle_text = @ref || @project.default_branch
= form_tag nil, method: :get, class: "project-refs-target-form" do
= form_tag nil, method: :get, style: { display: 'none' }, class: "project-refs-target-form" do
= hidden_field_tag :destination, destination
- if defined?(path)
= hidden_field_tag :path, path
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1600"><path d="M1088 1248v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h192q40 0 68 28t28 68zm0-512v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68V736q0-40 28-68t68-28h192q40 0 68 28t28 68zm0-512v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68V224q0-40 28-68t68-28h192q40 0 68 28t28 68z"/></svg>
......@@ -23,6 +23,6 @@
= icon('lock fw', base: 'circle', class: 'fa-lg private-fork-icon')
%strong= pluralize(@private_forks_count, 'private fork')
%span &nbsp;you have no access to.
= paginate(projects, remote: remote, theme: "gitlab") if projects.respond_to? :total_pages
= paginate_collection(projects, remote: remote)
- else
.nothing-here-block No projects found
#repo{ data: { url: content_url, project_name: project.name, refs_url: refs_project_path(project, format: :json), project_url: project_path(project), project_id: project.id, can_commit: (!!can_push_branch?(project, @ref)).to_s } }
%repo
#repo{ data: { url: content_url,
project_name: project.name,
refs_url: refs_project_path(project, format: :json),
project_url: project_path(project),
project_id: project.id,
can_commit: (!!can_push_branch?(project, @ref)).to_s,
on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s } }
- if current_user
- if note.emoji_awardable?
- user_authored = note.user_authored?(current_user)
.note-actions-item
= link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
= icon('spinner spin')
%span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
%span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
%span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
- if note_editable
.note-actions-item
= button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
%span.link-highlight
= custom_icon('icon_pencil')
= render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
---
title: disabling notifications globally now properly turns off group/project added
emails
merge_request: 13325
author: @jneen
type: fixed
---
title: Improves performance of vue code by using vue files and moving svg out of data
function in pipeline schedule callout
merge_request:
author:
type: other
---
title: Use jQuery to control scroll behavior in job log for cross browser consistency
merge_request:
author:
---
title: Fix timeouts when creating projects in groups with many members
merge_request: 13508
author:
type: fixed
---
title: Project pending delete no longer return 500 error in admins projects view
merge_request: 13389
author:
---
title: Allow any logged in users to read_users_list even if it's restricted
merge_request: 13201
author:
---
title: Fixes new issue button for failed job returning 404
merge_request:
author:
---
title: Prevents jobs dropdown from closing in pipeline graph
merge_request:
author:
type: fixed
---
title: Don't rename namespace called system when upgrading from 9.1.x to 9.5
merge_request: 13228
author:
---
title: Fix inconsistent spacing for edit buttons on issues and merge request page
merge_request:
author:
type: fixed
---
title: Fix edit merge request and issues button inconsistent letter casing
merge_request:
author:
type: fixed
---
title: Fix links to group milestones from issue and merge request sidebar
merge_request:
author:
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.
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