diff options
55 files changed, 1435 insertions, 67 deletions
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 1325fc993b2..3d59410cbc2 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -43,6 +43,15 @@ export default { required: false, default: false, }, + activeFileKey: { + type: String, + required: false, + default: null, + }, + keyPrefix: { + type: String, + required: true, + }, }, data() { return { @@ -113,8 +122,9 @@ export default { <list-item :file="file" :action-component="itemActionComponent" - :key-prefix="title" + :key-prefix="keyPrefix" :staged-list="stagedList" + :active-file-key="activeFileKey" /> </li> </ul> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 03f3e4de83c..6c30b2a721d 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -30,6 +30,11 @@ export default { required: false, default: false, }, + activeFileKey: { + type: String, + required: false, + default: null, + }, }, computed: { iconName() { @@ -39,6 +44,12 @@ export default { iconClass() { return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; }, + fullKey() { + return `${this.keyPrefix}-${this.file.key}`; + }, + isActive() { + return this.activeFileKey === this.fullKey; + }, }, methods: { ...mapActions([ @@ -51,7 +62,7 @@ export default { openFileInEditor() { return this.openPendingTab({ file: this.file, - keyPrefix: this.keyPrefix.toLowerCase(), + keyPrefix: this.keyPrefix, }).then(changeViewer => { if (changeViewer) { this.updateViewer(viewerTypes.diff); @@ -70,7 +81,12 @@ export default { </script> <template> - <div class="multi-file-commit-list-item"> + <div + class="multi-file-commit-list-item" + :class="{ + 'is-active': isActive + }" + > <button type="button" class="multi-file-commit-list-path" diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index c5092d8e04d..46e8ee26d3c 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -6,7 +6,7 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; import * as consts from '../stores/modules/commit/constants'; -import { activityBarViews } from '../constants'; +import { activityBarViews, stageKeys } from '../constants'; export default { components: { @@ -27,11 +27,14 @@ export default { 'unusedSeal', ]), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), - ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges']), + ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges', 'activeFile']), ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), showStageUnstageArea() { return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal); }, + activeFileKey() { + return this.activeFile ? this.activeFile.key : null; + }, }, watch: { hasChanges() { @@ -44,6 +47,7 @@ export default { if (this.lastOpenedFile) { this.openPendingTab({ file: this.lastOpenedFile, + keyPrefix: this.lastOpenedFile.changed ? stageKeys.unstaged : stageKeys.staged, }) .then(changeViewer => { if (changeViewer) { @@ -62,6 +66,7 @@ export default { return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges()); }, }, + stageKeys, }; </script> @@ -88,19 +93,23 @@ export default { class="is-first" icon-name="unstaged" :title="__('Unstaged')" + :key-prefix="$options.stageKeys.unstaged" :file-list="changedFiles" action="stageAllChanges" :action-btn-text="__('Stage all')" item-action-component="stage-button" + :active-file-key="activeFileKey" /> <commit-files-list icon-name="staged" :title="__('Staged')" + :key-prefix="$options.stageKeys.staged" :file-list="stagedFiles" action="unstageAllChanges" :action-btn-text="__('Unstage all')" item-action-component="unstage-button" :staged-list="true" + :active-file-key="activeFileKey" /> </template> <empty-state diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index d365745d78b..96f181ff79d 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -2,6 +2,7 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '~/flash'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; +import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { activityBarViews, viewerTypes } from '../constants'; import Editor from '../lib/editor'; import ExternalLink from './external_link.vue'; @@ -9,6 +10,7 @@ import ExternalLink from './external_link.vue'; export default { components: { ContentViewer, + DiffViewer, ExternalLink, }, props: { @@ -29,9 +31,18 @@ export default { shouldHideEditor() { return this.file && this.file.binary && !this.file.content; }, + showContentViewer() { + return ( + (this.shouldHideEditor || this.file.viewMode === 'preview') && + (this.viewer !== viewerTypes.mr || !this.file.mrChange) + ); + }, + showDiffViewer() { + return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr; + }, editTabCSS() { return { - active: this.file.viewMode === 'edit', + active: this.file.viewMode === 'editor', }; }, previewTabCSS() { @@ -53,7 +64,7 @@ export default { if (this.currentActivityView !== activityBarViews.edit) { this.setFileViewMode({ file: this.file, - viewMode: 'edit', + viewMode: 'editor', }); } } @@ -62,7 +73,7 @@ export default { if (this.currentActivityView !== activityBarViews.edit) { this.setFileViewMode({ file: this.file, - viewMode: 'edit', + viewMode: 'editor', }); } }, @@ -197,7 +208,7 @@ export default { <a href="javascript:void(0);" role="button" - @click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> + @click.prevent="setFileViewMode({ file, viewMode: 'editor' })"> <template v-if="viewer === $options.viewerTypes.edit"> {{ __('Edit') }} </template> @@ -222,7 +233,7 @@ export default { /> </div> <div - v-show="!shouldHideEditor && file.viewMode === 'edit'" + v-show="!shouldHideEditor && file.viewMode ==='editor'" ref="editor" class="multi-file-editor-holder" :class="{ @@ -231,10 +242,18 @@ export default { > </div> <content-viewer - v-if="shouldHideEditor || file.viewMode === 'preview'" + v-if="showContentViewer" :content="file.content || file.raw" :path="file.rawPath || file.path" :file-size="file.size" :project-path="file.projectId"/> + <diff-viewer + v-if="showDiffViewer" + :diff-mode="file.mrChange.diffMode" + :new-path="file.mrChange.new_path" + :new-sha="currentMergeRequest.sha" + :old-path="file.mrChange.old_path" + :old-sha="currentMergeRequest.baseCommitSha" + :project-path="file.projectId"/> </div> </template> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 65886c02b92..12e0c3aeef0 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -21,7 +21,19 @@ export const viewerTypes = { diff: 'diff', }; +export const diffModes = { + replaced: 'replaced', + new: 'new', + deleted: 'deleted', + renamed: 'renamed', +}; + export const rightSidebarViews = { pipelines: 'pipelines-list', jobsDetail: 'jobs-detail', }; + +export const stageKeys = { + unstaged: 'unstaged', + staged: 'staged', +}; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 13f123b6630..5826f6cb828 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ import * as types from '../mutation_types'; +import { diffModes } from '../../constants'; export default { [types.SET_FILE_ACTIVE](state, { path, active }) { @@ -85,8 +86,19 @@ export default { }); }, [types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) { + let diffMode = diffModes.replaced; + if (mrChange.new_file) { + diffMode = diffModes.new; + } else if (mrChange.deleted_file) { + diffMode = diffModes.deleted; + } else if (mrChange.renamed_file) { + diffMode = diffModes.renamed; + } Object.assign(state.entries[file.path], { - mrChange, + mrChange: { + ...mrChange, + diffMode, + }, }); }, [types.SET_FILE_VIEWMODE](state, { file, viewMode }) { diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index e0b9766fbee..a04a33cd12d 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -39,7 +39,7 @@ export const dataStructure = () => ({ editorColumn: 1, fileLanguage: '', eol: '', - viewMode: 'edit', + viewMode: 'editor', previewMode: null, size: 0, parentPath: null, diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index f8b3d3061f0..d269c45203a 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -56,7 +56,7 @@ export default class MilestoneSelect { if (issueUpdateURL) { milestoneLinkTemplate = _.template( - '<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', + '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', ); milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; } diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js index 825de01b5a2..87213c94eda 100644 --- a/app/assets/javascripts/pages/sessions/new/username_validator.js +++ b/app/assets/javascripts/pages/sessions/new/username_validator.js @@ -62,13 +62,13 @@ export default class UsernameValidator { return this.setPendingState(); } - if (!this.state.available) { - return this.setUnavailableState(); - } - if (!this.state.valid) { return this.setInvalidState(); } + + if (!this.state.available) { + return this.setUnavailableState(); + } } interceptInvalid(event) { @@ -89,7 +89,6 @@ export default class UsernameValidator { setAvailabilityState(usernameTaken) { if (usernameTaken) { - this.state.valid = false; this.state.available = false; } else { this.state.available = true; diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index 7b5367ac19b..c274d3ab590 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -32,7 +32,10 @@ export default { <div class="file-container"> <div class="file-content"> <p class="prepend-top-10 file-info"> - {{ fileName }} ({{ fileSizeReadable }}) + {{ fileName }} + <template v-if="fileSize > 0"> + ({{ fileSizeReadable }}) + </template> </p> <a :href="path" diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue index a5999f909ca..6851029018a 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import { numberToHumanSize } from '../../../../lib/utils/number_utils'; export default { @@ -12,6 +13,10 @@ export default { required: false, default: 0, }, + renderInfo: { + type: Boolean, + default: true, + }, }, data() { return { @@ -26,14 +31,34 @@ export default { return numberToHumanSize(this.fileSize); }, }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeThrottled, false); + }, + mounted() { + // The onImgLoad may have happened before the control was actually mounted + this.onImgLoad(); + this.resizeThrottled = _.throttle(this.onImgLoad, 400); + window.addEventListener('resize', this.resizeThrottled, false); + }, methods: { onImgLoad() { const contentImg = this.$refs.contentImg; - this.isZoomable = - contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height; - this.width = contentImg.naturalWidth; - this.height = contentImg.naturalHeight; + if (contentImg) { + this.isZoomable = + contentImg.naturalWidth > contentImg.width || + contentImg.naturalHeight > contentImg.height; + + this.width = contentImg.naturalWidth; + this.height = contentImg.naturalHeight; + + this.$emit('imgLoaded', { + width: this.width, + height: this.height, + renderedWidth: contentImg.clientWidth, + renderedHeight: contentImg.clientHeight, + }); + } }, onImgClick() { if (this.isZoomable) this.isZoomed = !this.isZoomed; @@ -47,20 +72,22 @@ export default { <div class="file-content image_file"> <img ref="contentImg" - :class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }" + :class="{ 'is-zoomable': isZoomable, 'is-zoomed': isZoomed }" :src="path" :alt="path" @load="onImgLoad" @click="onImgClick"/> - <p class="file-info prepend-top-10"> + <p + v-if="renderInfo" + class="file-info prepend-top-10"> <template v-if="fileSize>0"> {{ fileSizeReadable }} </template> <template v-if="fileSize>0 && width && height"> - - + | </template> <template v-if="width && height"> - {{ width }} x {{ height }} + W: {{ width }} | H: {{ height }} </template> </p> </div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/constants.js b/app/assets/javascripts/vue_shared/components/diff_viewer/constants.js new file mode 100644 index 00000000000..6c1840361af --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/constants.js @@ -0,0 +1,12 @@ +export const diffModes = { + replaced: 'replaced', + new: 'new', + deleted: 'deleted', + renamed: 'renamed', +}; + +export const imageViewMode = { + twoup: 'twoup', + swipe: 'swipe', + onion: 'onion', +}; diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue new file mode 100644 index 00000000000..4eca3fd4e97 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -0,0 +1,70 @@ +<script> +import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils'; +import ImageDiffViewer from './viewers/image_diff_viewer.vue'; +import DownloadDiffViewer from './viewers/download_diff_viewer.vue'; + +export default { + props: { + diffMode: { + type: String, + required: true, + }, + newPath: { + type: String, + required: true, + }, + newSha: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + oldSha: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + viewer() { + if (!this.newPath) return null; + + const previewInfo = viewerInformationForPath(this.newPath); + if (!previewInfo) return DownloadDiffViewer; + + switch (previewInfo.id) { + case 'image': + return ImageDiffViewer; + default: + return DownloadDiffViewer; + } + }, + fullOldPath() { + return `${gon.relative_url_root}/${this.projectPath}/raw/${this.oldSha}/${this.oldPath}`; + }, + fullNewPath() { + return `${gon.relative_url_root}/${this.projectPath}/raw/${this.newSha}/${this.newPath}`; + }, + }, +}; +</script> + +<template> + <div + class="diff-file preview-container" + v-if="viewer"> + <component + :is="viewer" + :diff-mode="diffMode" + :new-path="fullNewPath" + :old-path="fullOldPath" + :project-path="projectPath" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/download_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/download_diff_viewer.vue new file mode 100644 index 00000000000..50389b6ae63 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/download_diff_viewer.vue @@ -0,0 +1,69 @@ +<script> +import DownloadViewer from '../../content_viewer/viewers/download_viewer.vue'; +import { diffModes } from '../constants'; + +export default { + components: { + DownloadViewer, + }, + props: { + diffMode: { + type: String, + required: true, + }, + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + diffModes, +}; +</script> + +<template> + <div class="diff-file-container"> + <div class="diff-viewer"> + <div + v-if="diffMode === $options.diffModes.replaced" + class="two-up view row"> + <div class="col-sm-6 deleted"> + <download-viewer + :path="oldPath" + :project-path="projectPath" + /> + </div> + <div class="col-sm-6 added"> + <download-viewer + :path="newPath" + :project-path="projectPath" + /> + </div> + </div> + <div + v-else-if="diffMode === $options.diffModes.new" + class="added"> + <download-viewer + :path="newPath" + :project-path="projectPath" + /> + </div> + <div + v-else + class="deleted"> + <download-viewer + :path="oldPath" + :project-path="projectPath" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue new file mode 100644 index 00000000000..efcc39197b0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue @@ -0,0 +1,160 @@ +<script> +import { pixeliseValue } from '../../../lib/utils/dom_utils'; +import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue'; + +export default { + components: { + ImageViewer, + }, + props: { + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + onionMaxWidth: undefined, + onionMaxHeight: undefined, + onionOldImgInfo: null, + onionNewImgInfo: null, + onionDraggerPos: 0, + onionOpacity: 1, + dragging: false, + }; + }, + computed: { + onionMaxPixelWidth() { + return pixeliseValue(this.onionMaxWidth); + }, + onionMaxPixelHeight() { + return pixeliseValue(this.onionMaxHeight); + }, + onionDraggerPixelPos() { + return pixeliseValue(this.onionDraggerPos); + }, + }, + beforeDestroy() { + document.body.removeEventListener('mouseup', this.stopDrag); + this.$refs.dragger.removeEventListener('mousedown', this.startDrag); + }, + methods: { + dragMove(e) { + if (!this.dragging) return; + const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left; + const dragTrackWidth = + this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; + + let leftValue = left; + if (leftValue < 0) leftValue = 0; + if (leftValue > dragTrackWidth) leftValue = dragTrackWidth; + + this.onionOpacity = left / dragTrackWidth; + this.onionDraggerPos = leftValue; + }, + startDrag() { + this.dragging = true; + document.body.style.userSelect = 'none'; + document.body.addEventListener('mousemove', this.dragMove); + }, + stopDrag() { + this.dragging = false; + document.body.style.userSelect = ''; + document.body.removeEventListener('mousemove', this.dragMove); + }, + prepareOnionSkin() { + if (this.onionOldImgInfo && this.onionNewImgInfo) { + this.onionMaxWidth = Math.max( + this.onionOldImgInfo.renderedWidth, + this.onionNewImgInfo.renderedWidth, + ); + this.onionMaxHeight = Math.max( + this.onionOldImgInfo.renderedHeight, + this.onionNewImgInfo.renderedHeight, + ); + + this.onionOpacity = 1; + this.onionDraggerPos = + this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; + + document.body.addEventListener('mouseup', this.stopDrag); + } + }, + onionNewImgLoaded(imgInfo) { + this.onionNewImgInfo = imgInfo; + this.prepareOnionSkin(); + }, + onionOldImgLoaded(imgInfo) { + this.onionOldImgInfo = imgInfo; + this.prepareOnionSkin(); + }, + }, +}; +</script> + +<template> + <div class="onion-skin view"> + <div + class="onion-skin-frame" + :style="{ + 'width': onionMaxPixelWidth, + 'height': onionMaxPixelHeight, + 'user-select': dragging === true ? 'none' : '', + }"> + <div + class="frame deleted" + :style="{ + 'width': onionMaxPixelWidth, + 'height': onionMaxPixelHeight, + }"> + <image-viewer + key="onionOldImg" + :render-info="false" + :path="oldPath" + :project-path="projectPath" + @imgLoaded="onionOldImgLoaded" + /> + </div> + <div + class="added frame" + ref="addedFrame" + :style="{ + 'opacity': onionOpacity, + 'width': onionMaxPixelWidth, + 'height': onionMaxPixelHeight, + }"> + <image-viewer + key="onionNewImg" + :render-info="false" + :path="newPath" + :project-path="projectPath" + @imgLoaded="onionNewImgLoaded" + /> + </div> + <div class="controls"> + <div class="transparent"></div> + <div + class="drag-track" + ref="dragTrack" + @mousedown="startDrag" + @mouseup="stopDrag"> + <div + class="dragger" + ref="dragger" + :style="{ 'left': onionDraggerPixelPos }"> + </div> + </div> + <div class="opaque"></div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue new file mode 100644 index 00000000000..fc513ebfce1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue @@ -0,0 +1,158 @@ +<script> +import _ from 'underscore'; +import { pixeliseValue } from '../../../lib/utils/dom_utils'; +import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue'; + +export default { + components: { + ImageViewer, + }, + props: { + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + dragging: false, + swipeOldImgInfo: null, + swipeNewImgInfo: null, + swipeMaxWidth: undefined, + swipeMaxHeight: undefined, + swipeBarPos: 1, + swipeWrapWidth: undefined, + }; + }, + computed: { + swipeMaxPixelWidth() { + return pixeliseValue(this.swipeMaxWidth); + }, + swipeMaxPixelHeight() { + return pixeliseValue(this.swipeMaxHeight); + }, + swipeWrapPixelWidth() { + return pixeliseValue(this.swipeWrapWidth); + }, + swipeBarPixelPos() { + return pixeliseValue(this.swipeBarPos); + }, + }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeThrottled, false); + document.body.removeEventListener('mouseup', this.stopDrag); + document.body.removeEventListener('mousemove', this.dragMove); + }, + mounted() { + window.addEventListener('resize', this.resize, false); + }, + methods: { + dragMove(e) { + if (!this.dragging) return; + + let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left; + const spaceLeft = 20; + const { clientWidth } = this.$refs.swipeFrame; + if (leftValue <= 0) { + leftValue = 0; + } else if (leftValue > clientWidth - spaceLeft) { + leftValue = clientWidth - spaceLeft; + } + + this.swipeWrapWidth = this.swipeMaxWidth - leftValue; + this.swipeBarPos = leftValue; + }, + startDrag() { + this.dragging = true; + document.body.style.userSelect = 'none'; + document.body.addEventListener('mousemove', this.dragMove); + }, + stopDrag() { + this.dragging = false; + document.body.style.userSelect = ''; + document.body.removeEventListener('mousemove', this.dragMove); + }, + prepareSwipe() { + if (this.swipeOldImgInfo && this.swipeNewImgInfo) { + // Add 2 for border width + this.swipeMaxWidth = + Math.max(this.swipeOldImgInfo.renderedWidth, this.swipeNewImgInfo.renderedWidth) + 2; + this.swipeWrapWidth = this.swipeMaxWidth; + this.swipeMaxHeight = + Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2; + + document.body.addEventListener('mouseup', this.stopDrag); + } + }, + swipeNewImgLoaded(imgInfo) { + this.swipeNewImgInfo = imgInfo; + this.prepareSwipe(); + }, + swipeOldImgLoaded(imgInfo) { + this.swipeOldImgInfo = imgInfo; + this.prepareSwipe(); + }, + resize: _.throttle(function throttledResize() { + this.swipeBarPos = 0; + }, 400), + }, +}; +</script> + +<template> + <div class="swipe view"> + <div + class="swipe-frame" + ref="swipeFrame" + :style="{ + 'width': swipeMaxPixelWidth, + 'height': swipeMaxPixelHeight, + }"> + <div class="frame deleted"> + <image-viewer + key="swipeOldImg" + ref="swipeOldImg" + :render-info="false" + :path="oldPath" + :project-path="projectPath" + @imgLoaded="swipeOldImgLoaded" + /> + </div> + <div + class="swipe-wrap" + ref="swipeWrap" + :style="{ + 'width': swipeWrapPixelWidth, + 'height': swipeMaxPixelHeight, + }"> + <div class="frame added"> + <image-viewer + key="swipeNewImg" + :render-info="false" + :path="newPath" + :project-path="projectPath" + @imgLoaded="swipeNewImgLoaded" + /> + </div> + </div> + <span + class="swipe-bar" + ref="swipeBar" + @mousedown="startDrag" + @mouseup="stopDrag" + :style="{ 'left': swipeBarPixelPos }"> + <span class="top-handle"></span> + <span class="bottom-handle"></span> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue new file mode 100644 index 00000000000..9c19266ecdf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue @@ -0,0 +1,41 @@ +<script> +import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue'; + +export default { + components: { + ImageViewer, + }, + props: { + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <div class="two-up view row"> + <div class="col-sm-6 frame deleted"> + <image-viewer + :path="oldPath" + :project-path="projectPath" + /> + </div> + <div class="col-sm-6 frame added"> + <image-viewer + :path="newPath" + :project-path="projectPath" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue new file mode 100644 index 00000000000..43b28f96a06 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -0,0 +1,109 @@ +<script> +import ImageViewer from '../../content_viewer/viewers/image_viewer.vue'; +import TwoUpViewer from './image_diff/two_up_viewer.vue'; +import SwipeViewer from './image_diff/swipe_viewer.vue'; +import OnionSkinViewer from './image_diff/onion_skin_viewer.vue'; +import { diffModes, imageViewMode } from '../constants'; + +export default { + components: { + ImageViewer, + TwoUpViewer, + SwipeViewer, + OnionSkinViewer, + }, + props: { + diffMode: { + type: String, + required: true, + }, + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + mode: imageViewMode.twoup, + }; + }, + methods: { + changeMode(newMode) { + this.mode = newMode; + }, + }, + diffModes, + imageViewMode, +}; +</script> + +<template> + <div class="diff-file-container"> + <div + class="diff-viewer" + v-if="diffMode === $options.diffModes.replaced"> + <div class="image js-replaced-image"> + <two-up-viewer + v-if="mode === $options.imageViewMode.twoup" + v-bind="$props"/> + <swipe-viewer + v-else-if="mode === $options.imageViewMode.swipe" + v-bind="$props"/> + <onion-skin-viewer + v-else-if="mode === $options.imageViewMode.onion" + v-bind="$props"/> + </div> + <div class="view-modes"> + <ul class="view-modes-menu"> + <li + :class="{ + active: mode === $options.imageViewMode.twoup + }" + @click="changeMode($options.imageViewMode.twoup)"> + {{ s__('ImageDiffViewer|2-up') }} + </li> + <li + :class="{ + active: mode === $options.imageViewMode.swipe + }" + @click="changeMode($options.imageViewMode.swipe)"> + {{ s__('ImageDiffViewer|Swipe') }} + </li> + <li + :class="{ + active: mode === $options.imageViewMode.onion + }" + @click="changeMode($options.imageViewMode.onion)"> + {{ s__('ImageDiffViewer|Onion skin') }} + </li> + </ul> + </div> + <div class="note-container"></div> + </div> + <div + v-else-if="diffMode === $options.diffModes.new" + class="diff-viewer added"> + <image-viewer + :path="newPath" + :project-path="projectPath" + /> + </div> + <div + v-else + class="diff-viewer deleted"> + <image-viewer + :path="oldPath" + :project-path="projectPath" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js new file mode 100644 index 00000000000..02f28da8bb0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js @@ -0,0 +1,5 @@ +export function pixeliseValue(val) { + return val ? `${val}px` : ''; +} + +export default {}; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index f77ec4b6a2c..f060254777c 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -400,3 +400,51 @@ span.idiff { color: $common-gray-light; border: 1px solid $common-gray-light; } + +.preview-container { + height: 100%; + overflow: auto; + + .file-container { + background-color: $gray-darker; + display: flex; + height: 100%; + align-items: center; + justify-content: center; + + text-align: center; + + .file-content { + padding: $gl-padding; + max-width: 100%; + max-height: 100%; + + img { + max-width: 90%; + max-height: 70vh; + } + + .is-zoomable { + cursor: pointer; + cursor: zoom-in; + + &.is-zoomed { + cursor: pointer; + cursor: zoom-out; + max-width: none; + max-height: none; + margin-right: $gl-padding; + } + } + } + + .file-info { + font-size: $label-font-size; + color: $diff-image-info-color; + } + } + + .md-previewer { + padding: $gl-padding; + } +} diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index f06c9dcdf8c..fbc97ec0c95 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -189,8 +189,22 @@ img { border: 1px solid $white-light; - background-image: linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%), - linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%); + background-image: linear-gradient( + 45deg, + $border-color 25%, + transparent 25%, + transparent 75%, + $border-color 75%, + $border-color 100% + ), + linear-gradient( + 45deg, + $border-color 25%, + transparent 25%, + transparent 75%, + $border-color 75%, + $border-color 100% + ); background-size: 10px 10px; background-position: 0 0, 5px 5px; max-width: 100%; @@ -395,6 +409,69 @@ .line_content { white-space: pre-wrap; } + + .diff-file-container { + .frame.deleted { + border: 0; + background-color: inherit; + + .image_file img { + border: 1px solid $deleted; + } + } + + .frame.added { + border: 0; + background-color: inherit; + + .image_file img { + border: 1px solid $added; + } + } + + .swipe.view, + .onion-skin.view { + .swipe-wrap { + top: 0; + right: 0; + } + + .frame.deleted { + top: 0; + right: 0; + } + + .swipe-bar { + top: 0; + + .top-handle { + top: -14px; + left: -7px; + } + + .bottom-handle { + bottom: -14px; + left: -7px; + } + } + + .file-container { + display: inline-block; + + .file-content { + padding: 0; + + img { + max-width: none; + } + } + } + } + + .onion-skin.view .controls { + bottom: -25px; + } + } } .file-content .diff-file { @@ -536,7 +613,7 @@ margin-right: 0; border-color: $white-light; cursor: pointer; - transition: all .1s ease-out; + transition: all 0.1s ease-out; @for $i from 1 through 4 { &:nth-child(#{$i}) { @@ -563,7 +640,7 @@ height: 24px; border-radius: 50%; padding: 0; - transition: transform .1s ease-out; + transition: transform 0.1s ease-out; z-index: 100; .collapse-icon { @@ -708,11 +785,35 @@ width: 100%; height: 10px; background-color: $white-light; - background-image: linear-gradient(45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), - linear-gradient(225deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), - linear-gradient(135deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), - linear-gradient(-45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%); - background-position: 5px 5px,0 5px,0 5px,5px 5px; + background-image: linear-gradient( + 45deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80% + ), + linear-gradient( + 225deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80% + ), + linear-gradient( + 135deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80% + ), + linear-gradient( + -45deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80% + ); + background-position: 5px 5px, 0 5px, 0 5px, 5px 5px; background-size: 10px 10px; background-repeat: repeat; } @@ -750,11 +851,16 @@ .frame.click-to-comment { position: relative; cursor: image-url('illustrations/image_comment_light_cursor.svg') - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + auto; // Retina cursor - cursor: -webkit-image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; + cursor: -webkit-image-set( + image-url('illustrations/image_comment_light_cursor.svg') 1x, + image-url('illustrations/image_comment_light_cursor@2x.svg') 2x + ) + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + auto; .comment-indicator { position: absolute; @@ -840,7 +946,7 @@ .diff-notes-collapse, .note, - .discussion-reply-holder, { + .discussion-reply-holder { display: none; } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 6e7fc50c63d..4b8a3db1d06 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -335,7 +335,6 @@ img { max-width: 90%; - max-height: 90%; } .isZoomable { @@ -553,6 +552,10 @@ } .multi-file-commit-list-item { + &.is-active { + background-color: $white-normal; + } + .multi-file-discard-btn { display: none; margin-top: -2px; diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index c6ea12cbe9d..b8ecfc4ee2b 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -73,6 +73,15 @@ module ObjectStorage upload.id) end + def exclusive_lease_key + # For FileUploaders, model may have many uploaders. In that case + # we want to use exclusive key per upload, not per model to allow + # parallel migration + key_object = upload || model + + "object_storage_migrate:#{key_object.class}:#{key_object.id}" + end + private def current_upload_satisfies?(paths, model) @@ -316,6 +325,10 @@ module ObjectStorage super end + def exclusive_lease_key + "object_storage_migrate:#{model.class}:#{model.id}" + end + private def schedule_background_upload? @@ -382,10 +395,6 @@ module ObjectStorage end end - def exclusive_lease_key - "object_storage_migrate:#{model.class}:#{model.id}" - end - def with_exclusive_lease lease_key = exclusive_lease_key uuid = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.hour.to_i).try_obtain diff --git a/changelogs/unreleased/35158-snippets-api-visibility.yml b/changelogs/unreleased/35158-snippets-api-visibility.yml new file mode 100644 index 00000000000..f06015dda46 --- /dev/null +++ b/changelogs/unreleased/35158-snippets-api-visibility.yml @@ -0,0 +1,5 @@ +--- +title: Expose visibility via Snippets API +merge_request: 19620 +author: Jan Beckmann +type: added diff --git a/changelogs/unreleased/45575-invalid-characters-signup.yml b/changelogs/unreleased/45575-invalid-characters-signup.yml new file mode 100644 index 00000000000..679bd13e59b --- /dev/null +++ b/changelogs/unreleased/45575-invalid-characters-signup.yml @@ -0,0 +1,5 @@ +--- +title: 'Fix username validation order on signup, resolves #45575' +merge_request: 19610 +author: Jan Beckmann +type: fixed diff --git a/changelogs/unreleased/47050-quick-actions-case-insensitive.yml b/changelogs/unreleased/47050-quick-actions-case-insensitive.yml new file mode 100644 index 00000000000..176aba627b9 --- /dev/null +++ b/changelogs/unreleased/47050-quick-actions-case-insensitive.yml @@ -0,0 +1,5 @@ +--- +title: Make quick commands case insensitive +merge_request: 19614 +author: Jan Beckmann +type: fixed diff --git a/changelogs/unreleased/47513-upload-migration-lease-key-is-incorrect-for-non-mounted-uploaders.yml b/changelogs/unreleased/47513-upload-migration-lease-key-is-incorrect-for-non-mounted-uploaders.yml new file mode 100644 index 00000000000..010c4e9bce7 --- /dev/null +++ b/changelogs/unreleased/47513-upload-migration-lease-key-is-incorrect-for-non-mounted-uploaders.yml @@ -0,0 +1,5 @@ +--- +title: Use upload ID for creating lease key for file uploaders. +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/tz-diff-blob-image-viewer.yml b/changelogs/unreleased/tz-diff-blob-image-viewer.yml new file mode 100644 index 00000000000..81d87bc71f5 --- /dev/null +++ b/changelogs/unreleased/tz-diff-blob-image-viewer.yml @@ -0,0 +1,5 @@ +--- +title: Web IDE supports now Image + Download Diff Viewing +merge_request: 18768 +author: +type: added diff --git a/doc/api/snippets.md b/doc/api/snippets.md index 42b760c107d..7892866cd8e 100644 --- a/doc/api/snippets.md +++ b/doc/api/snippets.md @@ -49,6 +49,7 @@ Example response: "title": "test", "file_name": "add.rb", "description": "Ruby test snippet", + "visibility": "private", "author": { "id": 1, "username": "john_smith", @@ -99,6 +100,7 @@ Example response: "title": "This is a snippet", "file_name": "test.txt", "description": "Hello World snippet", + "visibility": "internal", "author": { "id": 1, "username": "john_smith", @@ -150,6 +152,7 @@ Example response: "title": "test", "file_name": "add.rb", "description": "description of snippet", + "visibility": "internal", "author": { "id": 1, "username": "john_smith", @@ -238,7 +241,8 @@ Example response: "raw_url": "http://localhost:3000/snippets/48/raw", "title": "Minus similique nesciunt vel fugiat qui ullam sunt.", "updated_at": "2016-11-25T16:53:34.479Z", - "web_url": "http://localhost:3000/snippets/48" + "web_url": "http://localhost:3000/snippets/48", + "visibility": "public" } ] ``` diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md index 7eab825fa32..aa2fcd82787 100644 --- a/doc/user/project/issue_board.md +++ b/doc/user/project/issue_board.md @@ -237,13 +237,15 @@ Issue Board, that is create/delete lists and drag issues around. ## Group Issue Board ->Introduced in GitLab 10.6 +> Introduced in [GitLab 10.6](https://about.gitlab.com/2018/03/22/gitlab-10-6-released/#single-group-issue-board-in-core-and-free) Group issue board is analogous to project-level issue board and it is accessible at the group navigation level. A group-level issue board allows you to view all issues from all projects in that group or descendant subgroups. Similarly, you can only filter by group labels for these boards. When updating milestones and labels for an issue through the sidebar update mechanism, again only group-level objects are available. +One group issue board per group was made available in GitLab 10.6 Core after multiple group issue boards were originally introduced in [GitLab 10.0 Premium](https://about.gitlab.com/2017/09/22/gitlab-10-0-released/#group-issue-boards). + ## Features per tier Different issue board features are available in different [GitLab tiers](https://about.gitlab.com/pricing/), as shown in the following table: diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md index 105d8a6ab61..b0143e45ab6 100644 --- a/doc/user/project/web_ide/index.md +++ b/doc/user/project/web_ide/index.md @@ -42,5 +42,26 @@ list. An additional review mode is available when you open a merge request, which shows you a preview of the merge request diff if you commit your changes. +## View CI job logs + +> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19279) [GitLab Core][ce] 11.0. + +The Web IDE can be used to quickly fix failing tests by opening the branch or +merge request in the Web IDE and opening the logs of the failed job. The status +of all jobs for the most recent pipeline and job traces for the current commit +can be accessed by clicking the **Pipelines** button in the top right. + +The pipeline status is also shown at all times in the status bar in the bottom +left. + +## Switching merge requests + +> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) [GitLab Core][ce] 11.0. + +Switching between your authored and assigned merge requests can be done without +leaving the Web IDE. Click the project name in the top left to open a list of +merge requests. You will need to commit or discard all your changes before +switching to a different merge request. + [ce]: https://about.gitlab.com/pricing/ [ee]: https://about.gitlab.com/pricing/ diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 52e716a7686..1cc8fcb8408 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -362,7 +362,7 @@ module API end class Snippet < Grape::Entity - expose :id, :title, :file_name, :description + expose :id, :title, :file_name, :description, :visibility expose :author, using: Entities::UserBasic expose :updated_at, :created_at expose :project_id @@ -416,6 +416,10 @@ module API expose :state, :created_at, :updated_at expose :due_date expose :start_date + + expose :web_url do |milestone, _options| + Gitlab::UrlBuilder.build(milestone) + end end class IssueBasic < ProjectEntity diff --git a/lib/banzai/filter/milestone_reference_filter.rb b/lib/banzai/filter/milestone_reference_filter.rb index b144bd8cf54..858e790005c 100644 --- a/lib/banzai/filter/milestone_reference_filter.rb +++ b/lib/banzai/filter/milestone_reference_filter.rb @@ -65,7 +65,7 @@ module Banzai # We don't support IID lookups for group milestones, because IIDs can # clash between group and project milestones. if project.group && !params[:iid] - finder_params[:group_ids] = [project.group.id] + finder_params[:group_ids] = project.group.self_and_ancestors.select(:id) end MilestonesFinder.new(finder_params).find_by(params) diff --git a/lib/gitlab/quick_actions/extractor.rb b/lib/gitlab/quick_actions/extractor.rb index 075ff91700c..30c6806b68e 100644 --- a/lib/gitlab/quick_actions/extractor.rb +++ b/lib/gitlab/quick_actions/extractor.rb @@ -39,7 +39,7 @@ module Gitlab content.delete!("\r") content.gsub!(commands_regex) do if $~[:cmd] - commands << [$~[:cmd], $~[:arg]].reject(&:blank?) + commands << [$~[:cmd].downcase, $~[:arg]].reject(&:blank?) '' else $~[0] @@ -102,14 +102,14 @@ module Gitlab # /close ^\/ - (?<cmd>#{Regexp.union(names)}) + (?<cmd>#{Regexp.new(Regexp.union(names).source, Regexp::IGNORECASE)}) (?: [ ] (?<arg>[^\n]*) )? (?:\n|$) ) - }mx + }mix end def perform_substitutions(content, commands) @@ -120,7 +120,7 @@ module Gitlab end substitution_definitions.each do |substitution| - match_data = substitution.match(content) + match_data = substitution.match(content.downcase) if match_data command = [substitution.name.to_s] command << match_data[1] unless match_data[1].empty? diff --git a/lib/gitlab/quick_actions/substitution_definition.rb b/lib/gitlab/quick_actions/substitution_definition.rb index 032c49ed159..688056e5d73 100644 --- a/lib/gitlab/quick_actions/substitution_definition.rb +++ b/lib/gitlab/quick_actions/substitution_definition.rb @@ -15,7 +15,7 @@ module Gitlab return unless content all_names.each do |a_name| - content.gsub!(%r{/#{a_name} ?(.*)$}, execute_block(action_block, context, '\1')) + content.gsub!(%r{/#{a_name} ?(.*)$}i, execute_block(action_block, context, '\1')) end content end diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb index 824e2d7251f..e64033b0dba 100644 --- a/lib/gitlab/url_builder.rb +++ b/lib/gitlab/url_builder.rb @@ -26,6 +26,8 @@ module Gitlab project_snippet_url(object.project, object) when Snippet snippet_url(object) + when Milestone + milestone_url(object) else raise NotImplementedError.new("No URL builder defined for #{object.class}") end diff --git a/spec/fixtures/api/schemas/public_api/v4/milestones.json b/spec/fixtures/api/schemas/public_api/v4/milestones.json index c3c42b6ee60..448e97d6c85 100644 --- a/spec/fixtures/api/schemas/public_api/v4/milestones.json +++ b/spec/fixtures/api/schemas/public_api/v4/milestones.json @@ -13,7 +13,8 @@ "created_at": { "type": "date" }, "updated_at": { "type": "date" }, "start_date": { "type": "date" }, - "due_date": { "type": "date" } + "due_date": { "type": "date" }, + "web_url": { "type": "string" } }, "required": [ "id", "iid", "title", "description", "state", diff --git a/spec/fixtures/api/schemas/public_api/v4/snippets.json b/spec/fixtures/api/schemas/public_api/v4/snippets.json index e37e9704649..d13d703e063 100644 --- a/spec/fixtures/api/schemas/public_api/v4/snippets.json +++ b/spec/fixtures/api/schemas/public_api/v4/snippets.json @@ -8,6 +8,7 @@ "title": { "type": "string" }, "file_name": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] }, + "visibility": { "type": "string" }, "web_url": { "type": "string" }, "created_at": { "type": "date" }, "updated_at": { "type": "date" }, diff --git a/spec/javascripts/fixtures/images/green_box.png b/spec/javascripts/fixtures/images/green_box.png Binary files differnew file mode 100644 index 00000000000..cd1ff9f9ade --- /dev/null +++ b/spec/javascripts/fixtures/images/green_box.png diff --git a/spec/javascripts/fixtures/images/red_box.png b/spec/javascripts/fixtures/images/red_box.png Binary files differnew file mode 100644 index 00000000000..73b2927da0f --- /dev/null +++ b/spec/javascripts/fixtures/images/red_box.png diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js index cc7e0a3f26d..8f7cf24c22f 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -19,6 +19,7 @@ describe('Multi-file editor commit sidebar list item', () => { vm = createComponentWithStore(Component, store, { file: f, actionComponent: 'stage-button', + activeFileKey: `staged-${f.key}`, }).$mount(); }); @@ -89,4 +90,20 @@ describe('Multi-file editor commit sidebar list item', () => { }); }); }); + + describe('is active', () => { + it('does not add active class when dont keys match', () => { + expect(vm.$el.classList).not.toContain('is-active'); + }); + + it('adds active class when keys match', done => { + vm.keyPrefix = 'staged'; + + vm.$nextTick(() => { + expect(vm.$el.classList).toContain('is-active'); + + done(); + }); + }); + }); }); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js index 54625ef90f8..6fb52378386 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js @@ -17,6 +17,8 @@ describe('Multi-file editor commit sidebar list', () => { action: 'stageAllChanges', actionBtnText: 'stage all', itemActionComponent: 'stage-button', + activeFileKey: 'staged-testing', + keyPrefix: 'staged', }); vm.$store.state.rightPanelCollapsed = false; diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js index 5e3e00a180b..531bcd6e540 100644 --- a/spec/javascripts/ide/components/repo_commit_section_spec.js +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -56,7 +56,7 @@ describe('RepoCommitSection', () => { vm.$store.state.entries[f.path] = f; }); - return vm.$mount(); + return vm; } beforeEach(done => { @@ -64,6 +64,10 @@ describe('RepoCommitSection', () => { vm = createComponent(); + spyOn(vm, 'openPendingTab').and.callThrough(); + + vm.$mount(); + spyOn(service, 'getTreeData').and.returnValue( Promise.resolve({ headers: { @@ -98,6 +102,7 @@ describe('RepoCommitSection', () => { store.state.noChangesStateSvgPath = 'nochangessvg'; store.state.committedStateSvgPath = 'svg'; + vm.$destroy(); vm = createComponentWithStore(Component, store).$mount(); expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes'); @@ -176,5 +181,12 @@ describe('RepoCommitSection', () => { expect(store.state.openFiles.length).toBe(1); expect(store.state.openFiles[0].pending).toBe(true); }); + + it('calls openPendingTab', () => { + expect(vm.openPendingTab).toHaveBeenCalledWith({ + file: vm.lastOpenedFile, + keyPrefix: 'unstaged', + }); + }); }); }); diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js index e83961fcedc..52f83be8e8c 100644 --- a/spec/javascripts/ide/stores/mutations/file_spec.js +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -152,6 +152,53 @@ describe('IDE store file mutations', () => { expect(localFile.mrChange.diff).toBe('ABC'); }); + + it('has diffMode replaced by default', () => { + mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, { + file: localFile, + mrChange: { + diff: 'ABC', + }, + }); + + expect(localFile.mrChange.diffMode).toBe('replaced'); + }); + + it('has diffMode new', () => { + mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, { + file: localFile, + mrChange: { + diff: 'ABC', + new_file: true, + }, + }); + + expect(localFile.mrChange.diffMode).toBe('new'); + }); + + it('has diffMode deleted', () => { + mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, { + file: localFile, + mrChange: { + diff: 'ABC', + deleted_file: true, + }, + }); + + expect(localFile.mrChange.diffMode).toBe('deleted'); + }); + + it('has diffMode renamed', () => { + mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, { + file: localFile, + mrChange: { + diff: 'ABC', + renamed_file: true, + }, + }); + + expect(localFile.mrChange.diffMode).toBe('renamed'); + }); }); describe('DISCARD_FILE_CHANGES', () => { diff --git a/spec/javascripts/test_constants.js b/spec/javascripts/test_constants.js index df59195e9f6..a820dd2d09c 100644 --- a/spec/javascripts/test_constants.js +++ b/spec/javascripts/test_constants.js @@ -2,3 +2,6 @@ export const FIXTURES_PATH = '/base/spec/javascripts/fixtures'; export const TEST_HOST = 'http://test.host'; export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`; + +export const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/green_box.png`; +export const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/red_box.png`; diff --git a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js index 383f0cd29ea..e2c34508b0d 100644 --- a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js @@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants'; describe('ContentViewer', () => { let vm; @@ -41,12 +42,12 @@ describe('ContentViewer', () => { it('renders image preview', done => { createComponent({ - path: 'test.jpg', + path: GREEN_BOX_IMAGE_URL, fileSize: 1024, }); setTimeout(() => { - expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe('test.jpg'); + expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); done(); }); @@ -59,9 +60,8 @@ describe('ContentViewer', () => { }); setTimeout(() => { - expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain( - 'test.abc (1.00 KiB)', - ); + expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('test.abc'); + expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('(1.00 KiB)'); expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download'); done(); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js new file mode 100644 index 00000000000..71d9145bf22 --- /dev/null +++ b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; +import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; + +describe('DiffViewer', () => { + let vm; + + function createComponent(props) { + const DiffViewer = Vue.extend(diffViewer); + vm = mountComponent(DiffViewer, props); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders image diff', done => { + window.gon = { + relative_url_root: '', + }; + + createComponent({ + diffMode: 'replaced', + newPath: GREEN_BOX_IMAGE_URL, + newSha: 'ABC', + oldPath: RED_BOX_IMAGE_URL, + oldSha: 'DEF', + projectPath: '', + }); + + setTimeout(() => { + expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe( + `//raw/DEF/${RED_BOX_IMAGE_URL}`, + ); + + expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe( + `//raw/ABC/${GREEN_BOX_IMAGE_URL}`, + ); + + done(); + }); + }); + + it('renders fallback download diff display', done => { + createComponent({ + diffMode: 'replaced', + newPath: 'test.abc', + newSha: 'ABC', + oldPath: 'testold.abc', + oldSha: 'DEF', + }); + + setTimeout(() => { + expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain( + 'testold.abc', + ); + expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain( + 'Download', + ); + + expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc'); + expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain( + 'Download', + ); + + done(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js new file mode 100644 index 00000000000..b878286ae3f --- /dev/null +++ b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js @@ -0,0 +1,185 @@ +import Vue from 'vue'; +import imageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; + +describe('ImageDiffViewer', () => { + let vm; + + function createComponent(props) { + const ImageDiffViewer = Vue.extend(imageDiffViewer); + vm = mountComponent(ImageDiffViewer, props); + } + + const triggerEvent = (eventName, el = vm.$el, clientX = 0) => { + const event = document.createEvent('MouseEvents'); + event.initMouseEvent( + eventName, + true, + true, + window, + 1, + clientX, + 0, + clientX, + 0, + false, + false, + false, + false, + 0, + null, + ); + + el.dispatchEvent(event); + }; + + const dragSlider = (sliderElement, dragPixel = 20) => { + triggerEvent('mousedown', sliderElement); + triggerEvent('mousemove', document.body, dragPixel); + triggerEvent('mouseup', document.body); + }; + + afterEach(() => { + vm.$destroy(); + }); + + it('renders image diff for replaced', done => { + createComponent({ + diffMode: 'replaced', + newPath: GREEN_BOX_IMAGE_URL, + oldPath: RED_BOX_IMAGE_URL, + }); + + setTimeout(() => { + expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe( + GREEN_BOX_IMAGE_URL, + ); + expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe( + RED_BOX_IMAGE_URL, + ); + + expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('2-up'); + expect(vm.$el.querySelector('.view-modes-menu li:nth-child(2)').textContent.trim()).toBe( + 'Swipe', + ); + expect(vm.$el.querySelector('.view-modes-menu li:nth-child(3)').textContent.trim()).toBe( + 'Onion skin', + ); + + done(); + }); + }); + + it('renders image diff for new', done => { + createComponent({ + diffMode: 'new', + newPath: GREEN_BOX_IMAGE_URL, + oldPath: '', + }); + + setTimeout(() => { + expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe( + GREEN_BOX_IMAGE_URL, + ); + + done(); + }); + }); + + it('renders image diff for deleted', done => { + createComponent({ + diffMode: 'deleted', + newPath: '', + oldPath: RED_BOX_IMAGE_URL, + }); + + setTimeout(() => { + expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe( + RED_BOX_IMAGE_URL, + ); + + done(); + }); + }); + + describe('swipeMode', () => { + beforeEach(done => { + createComponent({ + diffMode: 'replaced', + newPath: GREEN_BOX_IMAGE_URL, + oldPath: RED_BOX_IMAGE_URL, + }); + + setTimeout(() => { + done(); + }); + }); + + it('switches to Swipe Mode', done => { + vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click(); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('Swipe'); + done(); + }); + }); + + it('drag handler is working', done => { + vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click(); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.swipe-bar').style.left).toBe('1px'); + expect(vm.$el.querySelector('.top-handle')).not.toBeNull(); + + dragSlider(vm.$el.querySelector('.swipe-bar'), 40); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.swipe-bar').style.left).toBe('-20px'); + done(); + }); + }); + }); + }); + + describe('onionSkin', () => { + beforeEach(done => { + createComponent({ + diffMode: 'replaced', + newPath: GREEN_BOX_IMAGE_URL, + oldPath: RED_BOX_IMAGE_URL, + }); + + setTimeout(() => { + done(); + }); + }); + + it('switches to Onion Skin Mode', done => { + vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click(); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe( + 'Onion skin', + ); + done(); + }); + }); + + it('has working drag handler', done => { + vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click(); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.dragger').style.left).toBe('100px'); + + dragSlider(vm.$el.querySelector('.dragger')); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.dragger').style.left).toBe('20px'); + expect(vm.$el.querySelector('.added.frame').style.opacity).toBe('0.2'); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/lib/utils/dom_utils_spec.js b/spec/javascripts/vue_shared/components/lib/utils/dom_utils_spec.js new file mode 100644 index 00000000000..2388660b0c2 --- /dev/null +++ b/spec/javascripts/vue_shared/components/lib/utils/dom_utils_spec.js @@ -0,0 +1,13 @@ +import * as domUtils from '~/vue_shared/components/lib/utils/dom_utils'; + +describe('domUtils', () => { + describe('pixeliseValue', () => { + it('should add px to a given Number', () => { + expect(domUtils.pixeliseValue(12)).toEqual('12px'); + }); + + it('should not add px to 0', () => { + expect(domUtils.pixeliseValue(0)).toEqual(''); + }); + }); +}); diff --git a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb index f8fa9b2d13d..91d4a60ba95 100644 --- a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' describe Banzai::Filter::MilestoneReferenceFilter do include FilterSpecHelper - let(:group) { create(:group, :public) } + let(:parent_group) { create(:group, :public) } + let(:group) { create(:group, :public, parent: parent_group) } let(:project) { create(:project, :public, group: group) } it 'requires project context' do @@ -340,6 +341,13 @@ describe Banzai::Filter::MilestoneReferenceFilter do expect(doc.css('a')).to be_empty end + + it 'supports parent group references', :nested_groups do + milestone.update!(group: parent_group) + + doc = reference_filter("See #{reference}") + expect(doc.css('a').first.text).to eq(milestone.name) + end end context 'group context' do diff --git a/spec/lib/gitlab/quick_actions/extractor_spec.rb b/spec/lib/gitlab/quick_actions/extractor_spec.rb index f7c288f2393..0166f6c2ee0 100644 --- a/spec/lib/gitlab/quick_actions/extractor_spec.rb +++ b/spec/lib/gitlab/quick_actions/extractor_spec.rb @@ -182,6 +182,14 @@ describe Gitlab::QuickActions::Extractor do expect(msg).to eq "hello\nworld" end + it 'extracts command case insensitive' do + msg = %(hello\n/PoWer @user.name %9.10 ~"bar baz.2"\nworld) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']] + expect(msg).to eq "hello\nworld" + end + it 'does not extract noop commands' do msg = %(hello\nworld\n/reopen\n/noop_command) msg, commands = extractor.extract_commands(msg) @@ -206,6 +214,14 @@ describe Gitlab::QuickActions::Extractor do expect(msg).to eq "hello\nworld\nthis is great? SHRUG" end + it 'extracts and performs substitution commands case insensitive' do + msg = %(hello\nworld\n/reOpen\n/sHRuG this is great?) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to eq [['reopen'], ['shrug', 'this is great?']] + expect(msg).to eq "hello\nworld\nthis is great? SHRUG" + end + it 'extracts and performs substitution commands with comments' do msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.) msg, commands = extractor.extract_commands(msg) diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb index b81749cf428..9f495a5d50b 100644 --- a/spec/lib/gitlab/url_builder_spec.rb +++ b/spec/lib/gitlab/url_builder_spec.rb @@ -22,6 +22,31 @@ describe Gitlab::UrlBuilder do end end + context 'when passing a Milestone' do + let(:group) { create(:group) } + let(:project) { create(:project, :public, namespace: group) } + + context 'belonging to a project' do + it 'returns a proper URL' do + milestone = create(:milestone, project: project) + + url = described_class.build(milestone) + + expect(url).to eq "#{Settings.gitlab['url']}/#{milestone.project.full_path}/milestones/#{milestone.iid}" + end + end + + context 'belonging to a group' do + it 'returns a proper URL' do + milestone = create(:milestone, group: group) + + url = described_class.build(milestone) + + expect(url).to eq "#{Settings.gitlab['url']}/groups/#{milestone.group.full_path}/-/milestones/#{milestone.iid}" + end + end + end + context 'when passing a MergeRequest' do it 'returns a proper URL' do merge_request = build_stubbed(:merge_request, iid: 42) diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb index b3e253befc6..c5456977b60 100644 --- a/spec/requests/api/snippets_spec.rb +++ b/spec/requests/api/snippets_spec.rb @@ -20,6 +20,7 @@ describe API::Snippets do private_snippet.id) expect(json_response.last).to have_key('web_url') expect(json_response.last).to have_key('raw_url') + expect(json_response.last).to have_key('visibility') end it 'hides private snippets from regular user' do @@ -112,6 +113,7 @@ describe API::Snippets do expect(json_response['title']).to eq(snippet.title) expect(json_response['description']).to eq(snippet.description) expect(json_response['file_name']).to eq(snippet.file_name) + expect(json_response['visibility']).to eq(snippet.visibility) end it 'returns 404 for invalid snippet id' do @@ -142,6 +144,7 @@ describe API::Snippets do expect(json_response['title']).to eq(params[:title]) expect(json_response['description']).to eq(params[:description]) expect(json_response['file_name']).to eq(params[:file_name]) + expect(json_response['visibility']).to eq(params[:visibility]) end it 'returns 400 for missing parameters' do diff --git a/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb index 1ecddc14d58..19800c6638f 100644 --- a/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb +++ b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb @@ -76,10 +76,8 @@ shared_examples "migrates" do |to_store:, from_store: nil| end context 'when migrate! is occupied by another process' do - let(:exclusive_lease_key) { "object_storage_migrate:#{subject.model.class}:#{subject.model.id}" } - before do - @uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain + @uuid = Gitlab::ExclusiveLease.new(subject.exclusive_lease_key, timeout: 1.hour.to_i).try_obtain end it 'does not execute migrate!' do @@ -95,7 +93,7 @@ shared_examples "migrates" do |to_store:, from_store: nil| end after do - Gitlab::ExclusiveLease.cancel(exclusive_lease_key, @uuid) + Gitlab::ExclusiveLease.cancel(subject.exclusive_lease_key, @uuid) end end diff --git a/spec/uploaders/object_storage_spec.rb b/spec/uploaders/object_storage_spec.rb index b497ddc3e67..c7f5694ff43 100644 --- a/spec/uploaders/object_storage_spec.rb +++ b/spec/uploaders/object_storage_spec.rb @@ -332,6 +332,18 @@ describe ObjectStorage do expect { uploader.use_file }.to raise_error(ObjectStorage::ExclusiveLeaseTaken) end end + + it 'can still migrate other files of the same model' do + uploader2 = uploader_class.new(object, :file) + uploader2.upload = create(:upload) + uploader.upload = create(:upload) + + when_file_is_in_use do + expect(uploader2).to receive(:unsafe_migrate!) + + uploader2.migrate!(described_class::Store::REMOTE) + end + end end describe '#fog_credentials' do |