diff options
87 files changed, 3583 insertions, 199 deletions
diff --git a/app/assets/images/new_repo.png b/app/assets/images/new_repo.png Binary files differnew file mode 100644 index 00000000000..ed3af06ab1d --- /dev/null +++ b/app/assets/images/new_repo.png diff --git a/app/assets/images/old_repo.png b/app/assets/images/old_repo.png Binary files differnew file mode 100644 index 00000000000..c3c3b791ad9 --- /dev/null +++ b/app/assets/images/old_repo.png diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 56fa0d71a9a..76b724e1bcb 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -13,6 +13,7 @@ const Api = { dockerfilePath: '/api/:version/templates/dockerfiles/:key', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', usersPath: '/api/:version/users.json', + commitPath: '/api/:version/projects/:id/repository/commits', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath) @@ -95,6 +96,21 @@ const Api = { .done(projects => callback(projects)); }, + 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({ + url, + type: 'POST', + contentType: 'application/json; charset=utf-8', + data: JSON.stringify(data), + dataType: 'json', + }) + .done(commitData => callback(commitData)) + .fail(message => callback(message.responseJSON)); + }, + // Return text for a specific license licenseText(key, data, callback) { const url = Api.buildUrl(Api.licensePath) diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index d5923266c60..38b41cf2a19 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -62,6 +62,7 @@ import VersionCheckImage from './version_check_image'; import Wikis from './wikis'; import ZenMode from './zen_mode'; import initSettingsPanels from './settings_panels'; +import ScrollHelper from './helpers/scroll_helper'; import initExperimentalFlags from './experimental_flags'; import OAuthRememberMe from './oauth_remember_me'; import PerformanceBar from './performance_bar'; @@ -69,6 +70,7 @@ import initNotes from './init_notes'; import initLegacyFilters from './init_legacy_filters'; import initIssuableSidebar from './init_issuable_sidebar'; import GpgBadges from './gpg_badges'; +import FeatureHelper from './helpers/feature_helper'; (function() { var Dispatcher; @@ -90,6 +92,9 @@ import GpgBadges from './gpg_badges'; if (!page) { return false; } + + ScrollHelper.setScrollWidth(); + path = page.split(':'); shortcut_handler = null; @@ -321,12 +326,11 @@ import GpgBadges from './gpg_badges'; case 'projects:show': shortcut_handler = new ShortcutsNavigation(); new NotificationsForm(); - if ($('#tree-slider').length) { - new TreeView(); - } - if ($('.blob-viewer').length) { - new BlobViewer(); - } + + if (FeatureHelper.isNewRepo()) break; + + if ($('#tree-slider').length) new TreeView(); + if ($('#blob-viewer').length) new BlobViewer(); break; case 'projects:edit': setupProjectEdit(); @@ -385,6 +389,9 @@ import GpgBadges from './gpg_badges'; break; case 'projects:tree:show': shortcut_handler = new ShortcutsNavigation(); + + if (FeatureHelper.isNewRepo()) break; + new TreeView(); new BlobViewer(); $('#tree-slider').waitForImages(function() { @@ -395,6 +402,7 @@ import GpgBadges from './gpg_badges'; shortcut_handler = true; break; case 'projects:blob:show': + if (FeatureHelper.isNewRepo()) break; new BlobViewer(); initBlob(); break; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 3babe273100..5cb31ddd500 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -2,7 +2,51 @@ /* global fuzzaldrinPlus */ import { isObject } from './lib/utils/type_utility'; -var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote; +var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, GitLabDropdownInput; + +GitLabDropdownInput = (function() { + function GitLabDropdownInput(input, options) { + var $inputContainer, $clearButton; + var _this = this; + this.input = input; + this.options = options; + this.fieldName = this.options.fieldName || 'field-name'; + $inputContainer = this.input.parent(); + $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + $clearButton.on('click', (function(_this) { + // Clear click + return function(e) { + e.preventDefault(); + e.stopPropagation(); + return _this.input.val('').trigger('input').focus(); + }; + })(this)); + + this.input + .on('keydown', function (e) { + var keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault(); + } + }) + .on('input', function(e) { + var val = e.currentTarget.value || 'new-branch' + val = val.split(' ').join('-') // replaces space with dash + .replace(/[^a-zA-Z0-9 -]/g, '').toLowerCase() //replace non alphanumeric + .replace(/(-)\1+/g, '-'); // replace repeated dashes + _this.cb(_this.options.fieldName, val, {}, true); + _this.input.closest('.dropdown') + .find('.dropdown-toggle-text') + .text(val); + }); + } + + GitLabDropdownInput.prototype.onInput = function(cb) { + this.cb = cb; + } + + return GitLabDropdownInput; +})(); GitLabDropdownFilter = (function() { var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS; @@ -188,7 +232,7 @@ GitLabDropdownRemote = (function() { })(); GitLabDropdown = (function() { - var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex; + var ACTIVE_CLASS, FILTER_INPUT, NO_FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex; LOADING_CLASS = "is-loading"; @@ -206,7 +250,9 @@ GitLabDropdown = (function() { CURSOR_SELECT_SCROLL_PADDING = 5; - FILTER_INPUT = '.dropdown-input .dropdown-input-field'; + FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)'; + + NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; function GitLabDropdown(el1, options) { var searchFields, selector, self; @@ -221,6 +267,7 @@ GitLabDropdown = (function() { this.dropdown = selector != null ? $(selector) : $(this.el).parent(); // Set Defaults this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); + this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); this.highlight = !!this.options.highlight; this.filterInputBlur = this.options.filterInputBlur != null ? this.options.filterInputBlur @@ -259,6 +306,10 @@ GitLabDropdown = (function() { }); } } + if(this.noFilterInput.length) { + this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options); + this.plainInput.onInput(this.addInput.bind(this)) + } // Init filterable if (this.options.filterable) { this.filter = new GitLabDropdownFilter(this.filterInput, { @@ -744,9 +795,13 @@ GitLabDropdown = (function() { } }; - GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) { + GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject, single) { var $input; // Create hidden input for form + if(single){ + $('input[name="' + fieldName + '"]').remove(); + } + $input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value); if (this.options.inputId != null) { $input.attr('id', this.options.inputId); @@ -762,7 +817,7 @@ GitLabDropdown = (function() { $input.attr('data-meta', selectedObject[this.options.inputMeta]); } - return this.dropdown.before($input); + this.dropdown.before($input).trigger('change'); }; GitLabDropdown.prototype.selectRowAtIndex = function(index) { diff --git a/app/assets/javascripts/helpers/feature_helper.js b/app/assets/javascripts/helpers/feature_helper.js new file mode 100644 index 00000000000..469b65ec4d8 --- /dev/null +++ b/app/assets/javascripts/helpers/feature_helper.js @@ -0,0 +1,11 @@ +import Cookies from 'js-cookie'; + +function isNewRepo() { + return Cookies.get('new_repo') === 'true'; +} + +const FeatureHelper = { + isNewRepo, +}; + +export default FeatureHelper; diff --git a/app/assets/javascripts/helpers/scroll_helper.js b/app/assets/javascripts/helpers/scroll_helper.js new file mode 100644 index 00000000000..e921f9e2e0f --- /dev/null +++ b/app/assets/javascripts/helpers/scroll_helper.js @@ -0,0 +1,31 @@ +import $ from 'jquery'; + +const ScrollHelper = { + getScrollWidth() { + const $rulerContainer = $('<div>').css({ + visibility: 'hidden', + width: 100, + overflow: 'scroll', + }); + + const $ruler = $('<div>').css({ + width: 100, + }); + + $ruler.appendTo($rulerContainer); + + $rulerContainer.appendTo('body'); + + const scrollWidth = $ruler.get(0).offsetWidth; + + $rulerContainer.remove(); + + return 100 - scrollWidth; + }, + + setScrollWidth() { + $('body').attr('data-scroll-width', ScrollHelper.getScrollWidth()); + }, +}; + +export default ScrollHelper; diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index a3f7d69b98d..bae613aab90 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -118,9 +118,15 @@ import Cookies from 'js-cookie'; e.preventDefault(); if ($('input[name="ref"]').length) { var $form = $dropdown.closest('form'); + + var $visit = $dropdown.data('visit'); + var shouldVisit = typeof $visit === 'undefined' ? true : $visit; var action = $form.attr('action'); var divider = action.indexOf('?') === -1 ? '?' : '&'; - gl.utils.visitUrl(action + '' + divider + '' + $form.serialize()); + if(shouldVisit){ + gl.utils.visitUrl(action + '' + divider + '' + $form.serialize()); + } + } } }); diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js new file mode 100644 index 00000000000..a7c80098e22 --- /dev/null +++ b/app/assets/javascripts/repo/index.js @@ -0,0 +1,120 @@ +/* global monaco */ +import $ from 'jquery'; +import Vue from 'vue'; +import Translate from '../vue_shared/translate'; +import RepoSidebar from './repo_sidebar.vue'; +import EditButton from './repo_edit_button'; +import Service from './repo_service'; +import Store from './repo_store'; +import RepoCommitSection from './repo_commit_section.vue'; +import RepoTabs from './repo_tabs.vue'; +import RepoFileButtons from './repo_file_buttons.vue'; +import RepoBinaryViewer from './repo_binary_viewer.vue'; +import RepoEditor from './repo_editor.vue'; +import monacoLoader from './monaco_loader'; +import RepoMixin from './repo_mixin'; +import PopupDialog from '../vue_shared/components/popup_dialog.vue' + +Vue.use(Translate); + +function repoEditorLoader() { + return new Promise((resolve) => { + monacoLoader(['vs/editor/editor.main'], () => { + Store.monaco = monaco; + + resolve(RepoEditor); + }); + }); +} + +function initDropdowns() { + $('.project-refs-target-form').hide(); + $('.fa-long-arrow-right').hide(); +} + +function addEventsForNonVueEls() { + $(document).on('change', '.dropdown', () => { + Store.targetBranch = $('.project-refs-target-form input[name="ref"]').val(); + }); + + window.onbeforeunload = function (e) { + const hasChanged = Store.openedFiles + .some(file => file.changed); + if(!hasChanged) return; + e = e || window.event; + if (e) { + e.returnValue = 'Are you sure you want to lose unsaved changes?'; + } + // For Safari + return 'Are you sure you want to lose unsaved changes?'; + }; +} + +function initRepo() { + const repo = document.getElementById('repo'); + + + Store.service = Service; + Store.service.url = repo.dataset.url; + Store.service.refsUrl = repo.dataset.refsUrl; + Store.projectId = repo.dataset.projectId; + Store.projectName = repo.dataset.projectName; + Store.projectUrl = repo.dataset.projectUrl; + Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref'); + Store.checkIsCommitable(); + addEventsForNonVueEls(); + initDropdowns(); + + this.vm = new Vue({ + el: repo, + data: () => Store, + template: ` + <div class="tree-content-holder"> + <repo-sidebar/><div class="panel-right" :class="{'edit-mode': editMode}"> + <repo-tabs/> + <repo-file-buttons/> + <repo-editor/> + <repo-binary-viewer/> + </div> + <repo-commit-section/> + <popup-dialog + :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" + @submit="dialogSubmitted" + /> + </div> + `, + mixins: [RepoMixin], + components: { + 'repo-sidebar': RepoSidebar, + 'repo-tabs': RepoTabs, + 'repo-file-buttons': RepoFileButtons, + 'repo-binary-viewer': RepoBinaryViewer, + 'repo-editor': repoEditorLoader, + 'repo-commit-section': RepoCommitSection, + 'popup-dialog': PopupDialog, + }, + + methods: { + dialogToggled(toggle) { + this.dialog.open = toggle; + }, + + dialogSubmitted(status) { + this.dialog.open = false; + this.dialog.status = status; + } + } + }); + + const editButton = document.getElementById('editable-mode'); + Store.editButton = new EditButton(editButton); +} + +$(initRepo); + +export default initRepo; diff --git a/app/assets/javascripts/repo/monaco_loader.js b/app/assets/javascripts/repo/monaco_loader.js new file mode 100644 index 00000000000..ad1370a7730 --- /dev/null +++ b/app/assets/javascripts/repo/monaco_loader.js @@ -0,0 +1,13 @@ +/* eslint-disable no-underscore-dangle, camelcase */ +/* global __webpack_public_path__ */ + +import monacoContext from 'monaco-editor/dev/vs/loader'; + +monacoContext.require.config({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, + }, +}); + +window.__monaco_context__ = monacoContext; +export default monacoContext.require; diff --git a/app/assets/javascripts/repo/repo_binary_viewer.vue b/app/assets/javascripts/repo/repo_binary_viewer.vue new file mode 100644 index 00000000000..ee005366be2 --- /dev/null +++ b/app/assets/javascripts/repo/repo_binary_viewer.vue @@ -0,0 +1,68 @@ +<script> +import Store from './repo_store'; +import RepoHelper from './repo_helper'; + +const RepoBinaryViewer = { + data: () => Store, + + computed: { + pngBlobWithDataURI() { + if(this.binaryTypes.png){ + return `data:image/png;base64,${this.blobRaw}`; + } + return ''; + + }, + + svgBlobWithDataURI() { + if(this.binaryTypes.svg){ + return `data:image/svg+xml;utf8,${this.blobRaw}`; + } + return ''; + }, + }, + + methods: { + errored() { + Store.binaryLoaded = false; + }, + + loaded() { + Store.binaryLoaded = true; + }, + + getBinaryType() { + if(this.binaryTypes.hasOwnProperty(this.activeFile.extension)) { + return this.activeFile.extension; + } + return 'unknown'; + } + }, + + watch: { + blobRaw() { + Store.resetBinaryTypes(); + if (RepoHelper.isKindaBinary()) { + this.activeFile.raw = false; + // counts as binaryish so we use the binary viewer in this case. + this.binary = true; + } + if (!this.binary) return; + this.binaryTypes[this.getBinaryType()] = true; + }, + }, +}; + +export default RepoBinaryViewer; +</script> + +<template> +<div id="binary-viewer" v-if="binary && !activeFile.raw"> + <img v-show="binaryTypes.png && binaryLoaded" @error="errored" @load="loaded" :src="pngBlobWithDataURI" :alt="activeFile.name"/> + <img v-show="binaryTypes.svg" @error="errored" @load="loaded" :src="svgBlobWithDataURI" :alt="activeFile.name"/> + <div v-if="binaryTypes.md" v-html="activeFile.html"></div> + <div class="binary-unknown" v-if="binaryTypes.unknown"> + <span>Binary file. No preview available.</span> + </div> +</div> +</template> diff --git a/app/assets/javascripts/repo/repo_commit_section.vue b/app/assets/javascripts/repo/repo_commit_section.vue new file mode 100644 index 00000000000..caab02c221c --- /dev/null +++ b/app/assets/javascripts/repo/repo_commit_section.vue @@ -0,0 +1,100 @@ +<script> +/* global Flash */ +import Store from './repo_store'; +import Api from '../api'; +import RepoMixin from './repo_mixin' +import Helper from './repo_helper' + +const RepoCommitSection = { + data: () => Store, + + mixins: [RepoMixin], + + computed: { + branchPaths() { + let branch = Helper.getBranch(); + return this.changedFiles.map((f) => { + return Helper.getFilePathFromFullPath(f.url, branch); + }); + }, + + filePluralize() { + return this.changedFiles.length > 1 ? 'files' : 'file' + }, + }, + + 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) => { + const filePath = Helper.getFilePathFromFullPath(f.url, branch); + return { + action: 'update', + file_path: Helper.getFilePathFromFullPath(f.url, branch), + content: f.newContent, + }; + }); + const payload = { + branch: Store.targetBranch, + commit_message: commitMessage, + actions, + }; + Store.submitCommitsLoading = true; + Api.commitMultiple(Store.projectId, payload, (data) => { + Store.submitCommitsLoading = false; + Flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); + this.changedFiles = []; + this.openedFiles = []; + this.commitMessage = ''; + this.editMode = false; + $('html, body').animate({ scrollTop: 0 }, 'fast'); + }); + }, + }, +}; + +export default RepoCommitSection; +</script> + +<template> +<div id="commit-area" v-if="isCommitable && changedFiles.length" > + <form class="form-horizontal"> + <fieldset> + <div class="form-group"> + <label class="col-md-4 control-label staged-files">Staged files ({{changedFiles.length}})</label> + <div class="col-md-4"> + <ul class="list-unstyled changed-files"> + <li v-for="file in branchPaths"> + <span class="help-block">{{file}}</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> + </div> + </div> + <!-- Button Drop Down + --> + <div class="form-group"> + <label class="col-md-4 control-label" for="target-branch">Target branch</label> + <div class="col-md-4"> + <span class="help-block">{{targetBranch}}</span> + </div> + </div> + <div class="col-md-offset-4 col-md-4"> + <button type="submit" :disabled="!commitMessage || submitCommitsLoading" 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> + </button> + </div> + </fieldset> + </form> +</div> +</template> diff --git a/app/assets/javascripts/repo/repo_edit_button.js b/app/assets/javascripts/repo/repo_edit_button.js new file mode 100644 index 00000000000..27f3e4c1c20 --- /dev/null +++ b/app/assets/javascripts/repo/repo_edit_button.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import Store from './repo_store'; +import RepoMixin from './repo_mixin'; +import { __ } from '../locale'; + +export default class RepoEditButton { + constructor(el) { + this.initVue(el); + } + + initVue(el) { + this.vue = new Vue({ + el, + mixins: [RepoMixin], + data: () => Store, + computed: { + buttonLabel() { + return this.editMode ? __('Cancel edit') : __('Edit'); + }, + + buttonIcon() { + return this.editMode ? [] : ['fa', 'fa-pencil']; + }, + }, + methods: { + editClicked() { + if(this.changedFiles.length) { + this.dialog.open = true; + return; + } + this.editMode = !this.editMode; + }, + }, + }); + } +} diff --git a/app/assets/javascripts/repo/repo_editor.vue b/app/assets/javascripts/repo/repo_editor.vue new file mode 100644 index 00000000000..7bc5269ecd4 --- /dev/null +++ b/app/assets/javascripts/repo/repo_editor.vue @@ -0,0 +1,139 @@ +<script> +/* global monaco */ +import Store from './repo_store'; +import Helper from './repo_helper'; + +const RepoEditor = { + data: () => Store, + + mounted() { + const monacoInstance = this.monaco.editor.create(this.$el, { + model: null, + readOnly: true, + contextmenu: false, + }); + + Store.monacoInstance = monacoInstance; + + this.addMonacoEvents(); + + Helper.getContent().then(() => { + this.showHide(); + + if (this.blobRaw === '') return; + + const newModel = this.monaco.editor.createModel(this.blobRaw, 'plaintext'); + + this.monacoInstance.setModel(newModel); + }).catch(Helper.loadingError); + }, + + methods: { + showHide() { + if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) { + this.$el.style.display = 'none'; + } else { + this.$el.style.display = 'inline-block'; + } + }, + + addMonacoEvents() { + this.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp); + this.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this)); + }, + + onMonacoEditorKeysPressed() { + Store.setActiveFileContents(this.monacoInstance.getValue()); + }, + + onMonacoEditorMouseUp(e) { + if (e.target.element.className === 'line-numbers') { + location.hash = `L${e.target.position.lineNumber}`; + Store.activeLine = e.target.position.lineNumber; + } + }, + }, + + watch: { + activeLine() { + this.monacoInstance.setPosition({ + lineNumber: this.activeLine, + column: 1, + }); + }, + + editMode() { + const readOnly = !this.editMode; + + Store.readOnly = readOnly; + + this.monacoInstance.updateOptions({ + readOnly, + }); + + 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(); + } + }, + + activeFileLabel() { + this.showHide(); + }, + + dialog: { + handler(obj) { + if(obj.status) { + obj.status = false; + this.openedFiles.map((f) => { + if(f.active) { + this.blobRaw = f.plain; + } + f.changed = false; + delete f.newContent; + }); + this.editMode = false; + } + }, + deep: true, + }, + + isTree() { + this.showHide(); + }, + + openedFiles() { + this.showHide(); + }, + + 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); + }, + }, +}; + +export default RepoEditor; +</script> + +<template> +<div id="ide"></div> +</template> diff --git a/app/assets/javascripts/repo/repo_file.vue b/app/assets/javascripts/repo/repo_file.vue new file mode 100644 index 00000000000..571f3cd5bdb --- /dev/null +++ b/app/assets/javascripts/repo/repo_file.vue @@ -0,0 +1,60 @@ +<script> +import TimeAgoMixin from '../vue_shared/mixins/timeago'; + +const RepoFile = { + mixins: [TimeAgoMixin], + props: { + file: { + type: Object, + required: true, + }, + isMini: { + type: Boolean, + required: false, + default: false, + }, + loading: { + type: Object, + required: false, + default() { return { tree: false }; }, + }, + hasFiles: { + type: Boolean, + required: false, + default: false, + }, + activeFile: { + type: Object, + required: true, + }, + }, + + methods: { + linkClicked(file) { + this.$emit('linkclicked', file); + }, + }, +}; + +export default RepoFile; +</script> + +<template> +<tr class="file" v-if="!loading.tree || hasFiles" :class="{'active': activeFile.url === file.url}"> + <td @click.prevent="linkClicked(file)"> + <i class="fa" 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> + </td> + + <td v-if="!isMini" class="hidden-sm hidden-xs"> + <div class="commit-message"> + <a :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> +</tr> +</template> diff --git a/app/assets/javascripts/repo/repo_file_buttons.vue b/app/assets/javascripts/repo/repo_file_buttons.vue new file mode 100644 index 00000000000..4090ece420b --- /dev/null +++ b/app/assets/javascripts/repo/repo_file_buttons.vue @@ -0,0 +1,59 @@ +<script> +import Store from './repo_store'; +import Helper from './repo_helper'; +import RepoMixin from './repo_mixin'; + +const RepoFileButtons = { + data: () => Store, + + mixins: [RepoMixin], + + computed: { + + rawDownloadButtonLabel() { + return this.binary ? 'Download' : 'Raw'; + }, + + editableBorder() { + return this.editMode ? '1px solid rgb(31, 120, 209)' : '1px solid rgb(240,240,240)'; + }, + + canPreview() { + return Helper.isKindaBinary(); + }, + + rawFileURL() { + return Helper.getRawURLFromBlobURL(this.activeFile.url); + }, + + blameFileURL() { + return Helper.getBlameURLFromBlobURL(this.activeFile.url); + }, + + historyFileURL() { + return Helper.getHistoryURLFromBlobURL(this.activeFile.url); + }, + }, + + methods: { + rawPreviewToggle: Store.toggleRawPreview, + }, +}; + +export default RepoFileButtons; +</script> + +<template> +<div id="repo-file-buttons" v-if="isMini" :style="{'border-bottom': editableBorder}"> + <a :href="rawFileURL" target="_blank" class="btn btn-default raw">{{rawDownloadButtonLabel}}</a> + + <div class="btn-group" role="group" aria-label="File actions"> + <a :href="blameFileURL" class="btn btn-default blame">Blame</a> + <a :href="historyFileURL" class="btn btn-default history">History</a> + <a href="#" class="btn btn-default permalink">Permalink</a> + <a href="#" class="btn btn-default lock">Lock</a> + </div> + + <a href="#" v-if="canPreview" @click.prevent="rawPreviewToggle" class="btn btn-default preview">{{activeFileLabel}}</a> +</div> +</template> diff --git a/app/assets/javascripts/repo/repo_file_options.vue b/app/assets/javascripts/repo/repo_file_options.vue new file mode 100644 index 00000000000..322f4c23dd8 --- /dev/null +++ b/app/assets/javascripts/repo/repo_file_options.vue @@ -0,0 +1,39 @@ +<script> +const RepoFileOptions = { + props: { + isMini: { + type: Boolean, + required: false, + default: false, + }, + projectName: { + type: String, + required: true, + }, + }, +}; + +export default RepoFileOptions; +</script> + +<template> +<tr v-if="isMini" class="repo-file-options"> + <td> + <span class="title">{{projectName}}</span> + + <ul> + <li> + <a href="#" title="New File"> + <i class="fa fa-file-o"></i> + </a> + </li> + + <li> + <a href="#" title="New Folder"> + <i class="fa fa-folder-o"></i> + </a> + </li> + </ul> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/repo/repo_helper.js b/app/assets/javascripts/repo/repo_helper.js new file mode 100644 index 00000000000..de553e3b700 --- /dev/null +++ b/app/assets/javascripts/repo/repo_helper.js @@ -0,0 +1,294 @@ +/* global Flash */ +import Service from './repo_service'; +import Store from './repo_store'; +import '../flash'; + +const RepoHelper = { + getDefaultActiveFile() { + return { + active: true, + binary: false, + extension: '', + html: '', + mime_type: '', + name: 'loading...', + plain: '', + size: 0, + url: '', + raw: false, + newContent: '', + changed: false, + loading: false, + }; + }, + + key: '', + + isTree(data) { + return Object.hasOwnProperty.call(data, 'blobs'); + }, + + Time: window.performance + && window.performance.now + ? window.performance + : Date, + + getBranch() { + return $('button.dropdown-menu-toggle').attr('data-ref'); + }, + + getLanguageIDForFile(file, langs) { + const ext = file.name.split('.').pop(); + const foundLang = RepoHelper.findLanguage(ext, langs); + + return foundLang ? foundLang.id : 'plaintext'; + }, + + getFilePathFromFullPath(fullPath, branch) { + return fullPath.split(branch)[1]; + }, + + findLanguage(ext, langs) { + return langs.find(lang => lang.extensions && lang.extensions.indexOf(`.${ext}`) > -1); + }, + + setDirectoryOpen(tree) { + const file = tree; + if (!file) return undefined; + + file.opened = true; + file.icon = 'fa-folder-open'; + RepoHelper.toURL(file.url, file.name) + return file; + }, + + getRawURLFromBlobURL(url) { + return url.replace('blob', 'raw'); + }, + + isKindaBinary() { + const okExts = ['md', 'svg']; + return okExts.indexOf(Store.activeFile.extension) > -1; + }, + + getBlameURLFromBlobURL(url) { + return url.replace('blob', 'blame'); + }, + + getHistoryURLFromBlobURL(url) { + return url.replace('blob', 'commits'); + }, + + setBinaryDataAsBase64(url, file) { + Service.getBase64Content(url) + .then((response) => { + Store.blobRaw = response; + file.base64 = response; // eslint-disable-line no-param-reassign + }) + .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; + }, + + getNewMergedList(inDirectory, currentList, newList) { + const newListSorted = newList.sort(this.compareFilesCaseInsensitive); + if (!inDirectory) return newListSorted; + const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url); + if (!indexOfFile) return newListSorted; + return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile); + }, + + mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) { + newList.reverse().forEach((newFile) => { + const fileIndex = indexOfFile + 1; + const file = newFile; + file.level = inDirectory.level + 1; + oldList.splice(fileIndex, 0, file); + }); + + return oldList; + }, + + compareFilesCaseInsensitive(a, b) { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + if (a.level > 0) return 0; + if (aName < bName) { return -1; } + if (aName > bName) { return 1; } + return 0; + }, + + isRoot(url) { + // the url we are requesting -> split by the project URL. Grab the right side. + const isRoot = !!url.split(Store.projectUrl)[1] + // remove the first "/" + .slice(1) + // split this by "/" + .split('/') + // remove the first two items of the array... usually /tree/master. + .slice(2) + // we want to know the length of the array. + // If greater than 0 not root. + .length; + return isRoot; + }, + + getContent(treeOrFile, cb) { + 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 + const rawUrl = RepoHelper.getRawURLFromBlobURL(file.url || Service.url); + RepoHelper.setBinaryDataAsBase64(rawUrl, data); + data.binary = true; + } else { + Store.blobRaw = data.plain; + data.binary = false; + } + + if (!file.url) file.url = location.pathname; + + data.url = file.url; + data.newContent = ''; + + Store.addToOpenedFiles(data); + Store.setActiveFiles(data); + + // if the file tree is empty + if (Store.files.length === 0) { + const parentURL = Service.blobURLtoParentTree(Service.url); + Service.url = parentURL; + RepoHelper.getContent(); + } + } else { + // it's a tree + if (!file) Store.isRoot = RepoHelper.isRoot(Service.url); + file = RepoHelper.setDirectoryOpen(file); + const newDirectory = RepoHelper.dataToListOfFiles(data); + Store.addFilesToDirectory(file, Store.files, newDirectory); + Store.prevURL = Service.blobURLtoParentTree(Service.url); + } + }) + .catch(() => { + // RepoHelper.setLoading(false, loadingData); + RepoHelper.loadingError(); + }); + }, + + toFA(icon) { + return `fa-${icon}`; + }, + + serializeBlob(blob) { + const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob); + simpleBlob.lastCommitMessage = blob.last_commit.message; + simpleBlob.lastCommitUpdate = blob.last_commit.committed_date; + simpleBlob.loading = false; + + return simpleBlob; + }, + + serializeTree(tree) { + return RepoHelper.serializeRepoEntity('tree', tree); + }, + + serializeSubmodule(submodule) { + return RepoHelper.serializeRepoEntity('submodule', submodule); + }, + + serializeRepoEntity(type, entity) { + const { url, name, icon, last_commit } = entity; + return { + type, + name, + url, + lastCommitUrl: `${Store.projectUrl}/commit/${last_commit.id}`, + icon: RepoHelper.toFA(icon), + level: 0, + loading: false, + }; + }, + + scrollTabsRight() { + // wait for the transition. 0.1 seconds. + setTimeout(() => { + const tabs = document.getElementById('tabs'); + if (!tabs) return; + tabs.scrollLeft = 12000; + }, 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; + }, + + genKey() { + return RepoHelper.Time.now().toFixed(3); + }, + + getStateKey() { + return RepoHelper.key; + }, + + setStateKey(key) { + RepoHelper.key = key; + }, + + toURL(url, title) { + const history = window.history; + + RepoHelper.key = RepoHelper.genKey(); + + history.pushState({ key: RepoHelper.key }, '', url); + + if(title) { + document.title = `${title} · GitLab`; + } + }, + + loadingError() { + Flash('Unable to load the file at this time.'); + }, +}; + +export default RepoHelper; diff --git a/app/assets/javascripts/repo/repo_loading_file.vue b/app/assets/javascripts/repo/repo_loading_file.vue new file mode 100644 index 00000000000..c2d38ee50dc --- /dev/null +++ b/app/assets/javascripts/repo/repo_loading_file.vue @@ -0,0 +1,51 @@ +<script> +const RepoLoadingFile = { + props: { + loading: { + type: Object, + required: false, + default: {}, + }, + hasFiles: { + type: Boolean, + required: false, + default: false, + }, + isMini: { + type: Boolean, + required: false, + default: false, + }, + }, + + methods: { + lineOfCode(n) { + return `line-of-code-${n}`; + }, + }, +}; + +export default RepoLoadingFile; +</script> + +<template> +<tr v-if="loading.tree && !hasFiles" class="loading-file"> + <td> + <div class="animation-container animation-container-small"> + <div v-for="n in 6" :class="lineOfCode(n)"></div> + </div> + </td> + + <td v-if="!isMini" class="hidden-sm hidden-xs"> + <div class="animation-container"> + <div v-for="n in 6" :class="lineOfCode(n)"></div> + </div> + </td> + + <td v-if="!isMini" class="hidden-xs"> + <div class="animation-container animation-container-small"> + <div v-for="n in 6" :class="lineOfCode(n)"></div> + </div> + </td> +</tr> +</template> diff --git a/app/assets/javascripts/repo/repo_mixin.js b/app/assets/javascripts/repo/repo_mixin.js new file mode 100644 index 00000000000..be5df90b56b --- /dev/null +++ b/app/assets/javascripts/repo/repo_mixin.js @@ -0,0 +1,17 @@ +import Store from './repo_store'; + +const RepoMixin = { + computed: { + isMini() { + return !!Store.openedFiles.length; + }, + + changedFiles() { + const changedFileList = this.openedFiles + .filter(file => file.changed); + return changedFileList; + }, + }, +}; + +export default RepoMixin; diff --git a/app/assets/javascripts/repo/repo_prev_directory.vue b/app/assets/javascripts/repo/repo_prev_directory.vue new file mode 100644 index 00000000000..6a0d684052f --- /dev/null +++ b/app/assets/javascripts/repo/repo_prev_directory.vue @@ -0,0 +1,26 @@ +<script> +const RepoPreviousDirectory = { + props: { + prevUrl: { + type: String, + required: true, + }, + }, + + methods: { + linkClicked(file) { + this.$emit('linkclicked', file); + }, + }, +}; + +export default RepoPreviousDirectory; +</script> + +<template> +<tr class="prev-directory"> + <td colspan="3"> + <a :href="prevUrl" @click.prevent="linkClicked(prevUrl)">..</a> + </td> +</tr> +</template> diff --git a/app/assets/javascripts/repo/repo_service.js b/app/assets/javascripts/repo/repo_service.js new file mode 100644 index 00000000000..b16640ab4f0 --- /dev/null +++ b/app/assets/javascripts/repo/repo_service.js @@ -0,0 +1,67 @@ +import axios from 'axios'; +import Store from './repo_store'; + +const RepoService = { + url: '', + options: { + params: { + format: 'json', + }, + }, + richExtensionRegExp: /md/, + + checkCurrentBranchIsCommitable() { + const url = Store.service.refsUrl; + return axios.get(url, { params: { + ref: Store.currentBranch, + search: Store.currentBranch, + } }); + }, + + buildParams(url = this.url) { + // shallow clone object without reference + const params = Object.assign({}, this.options.params); + + if (this.urlIsRichBlob(url)) params.viewer = 'rich'; + + return params; + }, + + urlIsRichBlob(url = this.url) { + const extension = url.split('.').pop(); + + return this.richExtensionRegExp.test(extension); + }, + + getContent(url = this.url) { + const params = this.buildParams(url); + + return axios.get(url, { + params, + }); + }, + + getBase64Content(url = this.url) { + const request = axios.get(url, { + responseType: 'arraybuffer', + }); + + return request.then(response => this.bufferToBase64(response.data)); + }, + + bufferToBase64(data) { + return new Buffer(data, 'binary').toString('base64'); + }, + + blobURLtoParentTree(url) { + const urlArray = url.split('/'); + urlArray.pop(); + const blobIndex = urlArray.indexOf('blob'); + + if (blobIndex > -1) urlArray[blobIndex] = 'tree'; + + return urlArray.join('/'); + }, +}; + +export default RepoService; diff --git a/app/assets/javascripts/repo/repo_sidebar.vue b/app/assets/javascripts/repo/repo_sidebar.vue new file mode 100644 index 00000000000..11a2cde1169 --- /dev/null +++ b/app/assets/javascripts/repo/repo_sidebar.vue @@ -0,0 +1,106 @@ +<script> +import Service from './repo_service'; +import Helper from './repo_helper'; +import Store from './repo_store'; +import RepoPreviousDirectory from './repo_prev_directory.vue'; +import RepoFileOptions from './repo_file_options.vue'; +import RepoFile from './repo_file.vue'; +import RepoLoadingFile from './repo_loading_file.vue'; +import RepoMixin from './repo_mixin'; + +const RepoSidebar = { + mixins: [RepoMixin], + components: { + 'repo-file-options': RepoFileOptions, + 'repo-previous-directory': RepoPreviousDirectory, + 'repo-file': RepoFile, + 'repo-loading-file': RepoLoadingFile, + }, + + created() { + this.addPopEventListener(); + }, + + data: () => Store, + + methods: { + addPopEventListener() { + window.addEventListener('popstate', () => { + if (location.href.indexOf('#') > -1) return; + this.linkClicked({ + url: location.href, + }); + }); + }, + + linkClicked(clickedFile) { + let url = ''; + let file = clickedFile; + if (typeof file === 'object') { + 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, () => { + file.loading = false; + Helper.scrollTabsRight(); + }); + } + } else if (typeof file === 'string') { + // go back + url = file; + Service.url = url; + Helper.getContent(null, () => { + Helper.scrollTabsRight(); + }); + } + }, + }, +}; + +export default RepoSidebar; +</script> + +<template> +<div id="sidebar" :class="{'sidebar-mini' : isMini}" v-cloak> + <table class="table"> + <thead v-if="!isMini"> + <tr> + <th class="name">Name</th> + <th class="hidden-sm hidden-xs last-commit">Last Commit</th> + <th class="hidden-xs last-update">Last Update</th> + </tr> + </thead> + <tbody> + <repo-file-options + :is-mini="isMini" + :project-name="projectName"/> + <repo-previous-directory + v-if="isRoot" + :prev-url="prevURL" + @linkclicked="linkClicked(prevURL)"/> + <repo-loading-file + v-for="n in 5" + :key="n" + :loading="loading" + :has-files="!!files.length" + :is-mini="isMini"/> + <repo-file + v-for="file in files" + :key="file.id" + :file="file" + :is-mini="isMini" + @linkclicked="linkClicked(file)" + :is-tree="isTree" + :has-files="!!files.length" + :active-file="activeFile"/> + </tbody> + </table> +</div> +</template> diff --git a/app/assets/javascripts/repo/repo_store.js b/app/assets/javascripts/repo/repo_store.js new file mode 100644 index 00000000000..96a685a43f5 --- /dev/null +++ b/app/assets/javascripts/repo/repo_store.js @@ -0,0 +1,222 @@ +/* global Flash */ +import RepoHelper from './repo_helper'; + +const RepoStore = { + ideEl: {}, + monaco: {}, + monacoInstance: {}, + service: '', + editor: '', + sidebar: '', + editButton: '', + editMode: false, + isTree: false, + isRoot: false, + prevURL: '', + projectId: '', + projectName: '', + projectUrl: '', + trees: [], + blobs: [], + submodules: [], + blobRaw: '', + blobRendered: '', + openedFiles: [], + tabSize: 100, + defaultTabSize: 100, + minTabSize: 30, + tabsOverflow: 41, + submitCommitsLoading: false, + binaryLoaded:false, + dialog: { + open: false, + title: '', + status: false, + }, + activeFile: RepoHelper.getDefaultActiveFile(), + activeFileIndex: 0, + activeLine: 0, + activeFileLabel: 'Raw', + files: [], + isCommitable: false, + binary: false, + currentBranch: '', + targetBranch: 'new-branch', + commitMessage: '', + binaryMimeType: '', + // scroll bar space for windows + scrollWidth: 0, + binaryTypes: { + png: false, + md: false, + svg: false, + unknown: false, + }, + loading: { + tree: false, + blob: false, + }, + readOnly: true, + + resetBinaryTypes() { + let s = ''; + for(s in RepoStore.binaryTypes){ + RepoStore.binaryTypes[s] = false; + } + }, + + // 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.')); + }, + + addFilesToDirectory(inDirectory, currentList, newList) { + RepoStore.files = RepoHelper.getNewMergedList(inDirectory, currentList, newList); + }, + + toggleRawPreview() { + RepoStore.activeFile.raw = !RepoStore.activeFile.raw; + RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source'; + }, + + setActiveFiles(file) { + if (RepoStore.isActiveFile(file)) return; + RepoStore.openedFiles = RepoStore.openedFiles + .map((openedFile, i) => RepoStore.setFileActivity(file, openedFile, i)); + + RepoStore.setActiveToRaw(); + + if (file.binary) { + RepoStore.blobRaw = file.base64; + RepoStore.binaryMimeType = file.mime_type; + } else { + RepoStore.blobRaw = file.newContent || file.plain; + } + + if (!file.loading) RepoHelper.toURL(file.url, file.name); + RepoStore.binary = file.binary; + }, + + setFileActivity(file, openedFile, i) { + const activeFile = openedFile; + activeFile.active = file.url === activeFile.url; + + if (activeFile.active) RepoStore.setActiveFile(activeFile, i); + + return activeFile; + }, + + setActiveFile(activeFile, i) { + RepoStore.activeFile = Object.assign({}, RepoStore.activeFile, activeFile); + RepoStore.activeFileIndex = i; + }, + + setActiveToRaw() { + RepoStore.activeFile.raw = false; + // can't get vue to listen to raw for some reason so RepoStore for now. + RepoStore.activeFileLabel = 'Display source'; + }, + + removeChildFilesOfTree(tree) { + let foundTree = false; + const treeToClose = tree; + let wereDone = 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; + return true; + } + if (wereDone) return true; + + if (isItTheTreeWeWant) foundTree = true; + + if (foundTree) return file.level <= treeToClose.level; + return true; + }); + + treeToClose.opened = false; + treeToClose.icon = 'fa-folder'; + return treeToClose; + }, + + removeFromOpenedFiles(file) { + if (file.type === 'tree') return; + let foundIndex; + RepoStore.openedFiles = RepoStore.openedFiles.filter((openedFile, i) => { + if (openedFile.url === file.url) foundIndex = i; + return openedFile.url !== file.url; + }); + + // now activate the right tab based on what you closed. + if (RepoStore.openedFiles.length === 0) { + RepoStore.activeFile = {}; + return; + } + + if (RepoStore.openedFiles.length === 1 || foundIndex === 0) { + RepoStore.setActiveFiles(RepoStore.openedFiles[0]); + return; + } + + if (foundIndex) { + if (foundIndex > 0) { + RepoStore.setActiveFiles(RepoStore.openedFiles[foundIndex - 1]); + } + } + }, + + addPlaceholderFile() { + const randomURL = RepoHelper.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); + + if (openedFilesAlreadyExists) return; + + openFile.changed = false; + RepoStore.openedFiles.push(openFile); + }, + + setActiveFileContents(contents) { + if (!RepoStore.editMode) return; + const currentFile = RepoStore.openedFiles[RepoStore.activeFileIndex]; + RepoStore.activeFile.newContent = contents; + RepoStore.activeFile.changed = RepoStore.activeFile.plain !== RepoStore.activeFile.newContent; + currentFile.changed = RepoStore.activeFile.changed; + currentFile.newContent = contents; + }, + + // getters + + isActiveFile(file) { + return file && file.url === RepoStore.activeFile.url; + }, +}; +export default RepoStore; diff --git a/app/assets/javascripts/repo/repo_tab.vue b/app/assets/javascripts/repo/repo_tab.vue new file mode 100644 index 00000000000..58fc1e89f57 --- /dev/null +++ b/app/assets/javascripts/repo/repo_tab.vue @@ -0,0 +1,45 @@ +<script> +import RepoStore from './repo_store'; + +const RepoTab = { + props: { + tab: { + type: Object, + required: true, + }, + }, + + computed: { + changedClass() { + const tabChangedObj = { + 'fa-times': !this.tab.changed, + 'fa-circle': this.tab.changed, + }; + return tabChangedObj; + }, + }, + + methods: { + tabClicked: RepoStore.setActiveFiles, + + xClicked(file) { + if (file.changed) return; + this.$emit('xclicked', file); + }, + }, +}; + +export default RepoTab; +</script> + +<template> +<li> + <a href="#" class="close" @click.prevent="xClicked(tab)" v-if="!tab.loading"> + <i class="fa" :class="changedClass"></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> +</li> +</template> diff --git a/app/assets/javascripts/repo/repo_tabs.vue b/app/assets/javascripts/repo/repo_tabs.vue new file mode 100644 index 00000000000..a8364dd3a0c --- /dev/null +++ b/app/assets/javascripts/repo/repo_tabs.vue @@ -0,0 +1,42 @@ +<script> +import Vue from 'vue'; +import Store from './repo_store'; +import RepoTab from './repo_tab.vue'; +import RepoMixin from './repo_mixin'; + +const RepoTabs = { + mixins: [RepoMixin], + + components: { + 'repo-tab': RepoTab, + }, + + data: () => Store, + + methods: { + isOverflow() { + return this.$el.scrollWidth > this.$el.offsetWidth; + }, + + xclicked(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> +</template> diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js index ef401abce2d..8875590f0f2 100644 --- a/app/assets/javascripts/test_utils/index.js +++ b/app/assets/javascripts/test_utils/index.js @@ -1,3 +1,5 @@ +import 'core-js/es6/map'; +import 'core-js/es6/set'; import simulateDrag from './simulate_drag'; // Export to global space for rspec to use diff --git a/app/assets/javascripts/vue_shared/components/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/popup_dialog.vue new file mode 100644 index 00000000000..f63125e4d81 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/popup_dialog.vue @@ -0,0 +1,67 @@ +<script> +const PopupDialog = { + name: 'popup-dialog', + + props: { + open: Boolean, + title: String, + body: String, + kind: { + type: String, + default: 'primary', + }, + closeButtonLabel: { + type: String, + default: 'Cancel', + }, + primaryButtonLabel: { + type: String, + default: 'Save changes', + }, + }, + + computed: { + typeOfClass() { + const className = `btn-${this.kind}`; + let returnObj = {}; + returnObj[className] = true; + return returnObj; + } + }, + + methods: { + close() { + this.$emit('toggle', false); + }, + + yesClick() { + this.$emit('submit', true); + }, + + noClick() { + this.$emit('submit', false); + } + } +}; + +export default PopupDialog; +</script> +<template> +<div class="modal popup-dialog" tabindex="-1" v-show="open" role="dialog"> + <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">×</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> + </div> + </div> + </div> +</div> +</template>
\ No newline at end of file diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 67c3287ed74..09c596e168c 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -121,6 +121,13 @@ of the body element here, we negate cascading side effects but allow momentum sc -webkit-overflow-scrolling: auto; } +.truncate { + width: 250px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .with-performance-bar .page-with-sidebar { margin-top: $header-height + $performance-bar-height; } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 6f91d11b369..261642f4174 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -119,6 +119,13 @@ } } +@mixin truncate($width: 250px) { + width: $width; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* * Mixin for status badges, as used for pipelines and commit signatures */ diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 0df6f24bfe6..f02b2e72ffd 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -88,6 +88,7 @@ $indigo-950: #1a1a40; $black: #000; $black-transparent: rgba(0, 0, 0, 0.3); +$almost-black: #242424; $border-white-light: darken($white-light, $darken-border-factor); $border-white-normal: darken($white-normal, $darken-border-factor); @@ -614,6 +615,13 @@ $color-average-score: $orange-400; $color-low-score: $red-400; /* +Repo editor +*/ +$repo-editor-grey: #f6f7f9; +$repo-editor-grey-darker: #e9ebee; +$repo-editor-linear-gradient: linear-gradient(to right, $repo-editor-grey 0%, $repo-editor-grey-darker, 20%, $repo-editor-grey 40%, $repo-editor-grey 100%); + +/* Performance Bar */ $perf-bar-text: #999; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss new file mode 100644 index 00000000000..77c6952340a --- /dev/null +++ b/app/assets/stylesheets/pages/repo.scss @@ -0,0 +1,397 @@ +[v-cloak] { + display: none; +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity .5s; +} + +.modal.popup-dialog { + display: block; + + + @media (min-width: 992px) { + .modal-dialog { + width: 600px; + margin: 30px auto; + } + } +} + +.project-refs-form, .project-refs-target-form { + display: inline-block; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.fade-enter, +.fade-leave-to /* .fade-leave-active in <2.1.8 */ { + opacity: 0; +} + +.commit-message { + @include truncate(250px); +} + +.tree-content-holder { + border: 1px solid $border-color; + border-radius: $border-radius-default; + color: $almost-black; + + header { + background: $gray-light; + padding: 10px 15px; + } + + .panel-right { + display: inline-block; + width: 80%; + + .monaco-editor.vs { + .line-numbers { + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .cursor { + display: none !important; + } + } + + &.edit-mode { + .monaco-editor.vs { + .cursor { + background: $black; + border-color: $black; + display: block !important; + } + } + } + + #tabs { + border-bottom: 1px solid $white-normal; + padding-left: 0; + margin-bottom: 0; + display: inline-block; + white-space: nowrap; + width: 100%; + overflow-y: hidden; + overflow-x: auto; + + li { + animation: swipeRightAppear ease-in 0.1s; + animation-iteration-count: 1; + transform-origin: 0% 50%; + list-style-type: none; + background: $gray-normal; + display: inline-block; + padding: 10px 18px; + border-right: 1px solid $border-color; + white-space: nowrap; + border-radius: 3px 3px 0 0; + + &.remove { + animation: swipeRightDissapear ease-in 0.1s; + animation-iteration-count: 1; + transform-origin: 0% 50%; + a { + width: 0; + } + } + + &.active { + background: $white-light; + } + + a { + color: $black; + display: inline-block; + width: 100px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + + &.close { + width: auto; + font-size: 15px; + opacity: 1; + } + } + + i.fa.fa-times, + i.fa.fa-circle { + float: right; + margin-top: 3px; + margin-left: 15px; + color: $gray-darkest; + } + + i.fa.fa-circle { + color: $brand-success; + } + } + } + + #ide { + height: 75vh; + } + + #repo-file-buttons { + background: $gray-light; + border-bottom: 1px solid $white-normal; + padding: 10px 5px; + position: relative; + border-top: 1px solid $white-normal; + margin-top: -5px; + } + + #binary-viewer { + height: 80vh; + overflow: auto; + margin: 0; + + .blob-viewer { + padding-top: 20px; + padding-left: 20px; + } + + .binary-unknown { + text-align: center; + padding-top: 100px; + background: #FAFAFA; + height: 100%; + font-size: 17px; + span { + display: block; + } + } + } + } + + #commit-area { + background: $gray-light; + padding: 20px; + + span.help-block { + padding-top: 7px; + margin-top: 0; + } + } + + #view-toggler { + height: 41px; + position: relative; + display: block; + border-bottom: 1px solid $white-normal; + background: $white-light; + margin-top: -5px; + } + + #binary-viewer { + img { + max-width: 100%; + } + } + + #sidebar { + + &.sidebar-mini { + display: inline-block; + vertical-align: top; + width: 20%; + border-right: 1px solid $white-normal; + height: 100vh; + overflow: auto; + } + + tr { + animation: fadein 0.5s; + cursor: pointer; + + &.repo-file-options td { + padding: 0; + border-top: none; + background: $gray-light; + width: 190px; + display: inline-block; + + &:hover { + .title { + width: 105px; + } + + ul { + display: inline-block; + } + + } + + .title { + display: inline-block; + font-size: 10px; + text-transform: uppercase; + font-weight: bold; + color: $gray-darkest; + width: 185px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + padding: 2px 16px; + } + + ul { + display: none; + float: right; + margin: 0 10px 0 0; + padding: 1px 0; + + li { + display: inline-block; + padding: 0 2px; + border-bottom: none; + } + } + } + + .fa { + margin-right: 5px; + } + + td { + white-space: nowrap; + } + } + + a { + 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; + } +} + +@keyframes blockTextShine { + 0% { + transform: translateX(-468px); + } + + 100% { + transform: translateX(468px); + } +} + +@keyframes swipeRightAppear { + 0% { + transform: scaleX(0.00); + } + + 100% { + transform: scaleX(1.00); + } +} + +@keyframes swipeRightDissapear { + 0% { + transform: scaleX(1.00); + } + + 100% { + transform: scaleX(0.00); + } +} diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index dc88cf3e699..40052dcd882 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -86,7 +86,7 @@ } .add-to-tree { - vertical-align: top; + vertical-align: middle; padding: 6px 10px; } diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb index 54dcd7c61ce..5a1b1d9f929 100644 --- a/app/controllers/concerns/renders_blob.rb +++ b/app/controllers/concerns/renders_blob.rb @@ -11,11 +11,27 @@ module RendersBlob else blob.simple_viewer end + return render_404 unless viewer - render json: { - html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_async: false) - } + if blob.binary? + render json: { + binary: true, + mime_type: blob.mime_type, + name: blob.name, + extension: blob.extension, + size: blob.raw_size + } + else + render json: { + html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_async: false), + plain: blob.data, + name: blob.name, + extension: blob.extension, + size: blob.raw_size, + mime_type: blob.mime_type + } + end end def conditionally_expand_blob(blob) diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 49ea2945675..721e174d587 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -4,6 +4,7 @@ class Projects::BlobController < Projects::ApplicationController include CreatesCommit include RendersBlob include ActionView::Helpers::SanitizeHelper + include ApplicationHelper # Raised when given an invalid file path InvalidPathError = Class.new(StandardError) @@ -37,12 +38,13 @@ class Projects::BlobController < Projects::ApplicationController respond_to do |format| format.html do + assign_ref_vars + environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + @last_commit = @repository.last_commit_for_path(@commit.id, tree.path) || @commit - @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path) - - render 'show' + show_new_repo? ? render('projects/tree/show') : render('show') end format.json do diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 30181ac3bdf..1fc276b8c03 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -24,12 +24,19 @@ class Projects::TreeController < Projects::ApplicationController end end - @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit - respond_to do |format| - format.html - # Disable cache so browser history works - format.js { no_cache_headers } + format.html do + @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit + end + + format.js do + # Disable cache so browser history works + no_cache_headers + end + + format.json do + render json: TreeSerializer.new(project: @project, repository: @repository, ref: @ref).represent(@tree) + end end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 2d7cbd4614e..4f99845a5d9 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -220,13 +220,29 @@ class ProjectsController < Projects::ApplicationController end def refs - branches = BranchesFinder.new(@repository, params).execute.map(&:name) + find_refs = params['find'] + + find_branches = true + find_tags = true + find_commits = true + + if !find_refs.nil? + find_branches = find_refs.include? 'branches' + find_tags = find_refs.include? 'tags' + find_commits = find_refs.include? 'commits' + end - options = { - s_('RefSwitcher|Branches') => branches.take(100) - } + branches = [] + options = {} + + if find_branches + branches = BranchesFinder.new(@repository, params).execute.map(&:name) + options = { + s_('RefSwitcher|Branches') => branches.take(100) + } + end - unless @repository.tag_count.zero? + if @repository.tag_count.nonzero? && find_tags tags = TagsFinder.new(@repository, params).execute.map(&:name) options[s_('RefSwitcher|Tags')] = tags.take(100) @@ -234,7 +250,7 @@ class ProjectsController < Projects::ApplicationController # If reference is commit id - we should add it to branch/tag selectbox ref = Addressable::URI.unescape(params[:ref]) - if ref && options.flatten(2).exclude?(ref) && ref =~ /\A[0-9a-zA-Z]{6,52}\z/ + if ref && options.flatten(2).exclude?(ref) && ref =~ /\A[0-9a-zA-Z]{6,52}\z/ && find_commits options['Commits'] = [ref] end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 14dc9bd9d62..44dacff5997 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -305,4 +305,8 @@ module ApplicationHelper def show_new_nav? cookies["new_nav"] == "true" end + + def show_new_repo? + cookies["new_repo"] == "true" + end end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 91ddd73fac1..51ab2867e47 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -88,15 +88,15 @@ module DiffHelper end def submodule_link(blob, ref, repository = @repository) - tree, commit = submodule_links(blob, ref, repository) - commit_id = if commit.nil? + project_url, tree_url = submodule_links(blob, ref, repository) + commit_id = if tree_url.nil? Commit.truncate_sha(blob.id) else - link_to Commit.truncate_sha(blob.id), commit + link_to Commit.truncate_sha(blob.id), tree_url end [ - content_tag(:span, link_to(truncate(blob.name, length: 40), tree)), + content_tag(:span, link_to(truncate(blob.name, length: 40), project_url)), '@', content_tag(:span, commit_id, class: 'commit-sha') ].join(' ').html_safe diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index ac8c518ac84..ff305fa39b4 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -48,11 +48,11 @@ module DropdownsHelper end end - def dropdown_title(title, back: false) + def dropdown_title(title, options: {}) content_tag :div, class: "dropdown-title" do title_output = "" - if back + if options.fetch(:back, false) title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-back", aria: { label: "Go back" }, type: "button") do icon('arrow-left') end @@ -60,14 +60,25 @@ module DropdownsHelper title_output << content_tag(:span, title) - title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do - icon('times', class: 'dropdown-menu-close-icon') + if options.fetch(:close, true) + title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do + icon('times', class: 'dropdown-menu-close-icon') + end end title_output.html_safe end end + def dropdown_input(placeholder, input_id: nil) + content_tag :div, class: "dropdown-input" do + filter_output = text_field_tag input_id, nil, class: "dropdown-input-field dropdown-no-filter", placeholder: placeholder, autocomplete: 'off' + filter_output << icon('times', class: "dropdown-input-clear js-dropdown-input-clear", role: "button") + + filter_output.html_safe + end + end + def dropdown_filter(placeholder, search_id: nil) content_tag :div, class: "dropdown-input" do filter_output = search_field_tag search_id, nil, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off' diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index f29faeca22d..9a404832423 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -1,4 +1,5 @@ module IconsHelper + extend self include FontAwesome::Rails::IconHelper # Creates an icon tag given icon name(s) and possible icon modifiers. diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index b24039fb349..88f7702db1e 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -1,5 +1,5 @@ module SubmoduleHelper - include Gitlab::ShellAdapter + extend self VALID_SUBMODULE_PROTOCOLS = %w[http https git ssh].freeze @@ -59,7 +59,7 @@ module SubmoduleHelper return true if url_no_dotgit == [Gitlab.config.gitlab.url, '/', namespace, '/', project].join('') url_with_dotgit = url_no_dotgit + '.git' - url_with_dotgit == gitlab_shell.url_to_repo([namespace, '/', project].join('')) + url_with_dotgit == Gitlab::Shell.new.url_to_repo([namespace, '/', project].join('')) end def relative_self_url?(url) diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index e0d3e9b88f3..63ddf0cc8cb 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -12,6 +12,14 @@ module TreeHelper tree.html_safe end + def repo_url(project) + if controller_name == 'projects' + readme_path(project) || project_tree_path(project, project.default_branch) + else + request.original_url + end + end + # Return an image icon depending on the file type and mode # # type - String type of the tree item; either 'folder' or 'file' diff --git a/app/serializers/blob_entity.rb b/app/serializers/blob_entity.rb new file mode 100644 index 00000000000..56f173e5a27 --- /dev/null +++ b/app/serializers/blob_entity.rb @@ -0,0 +1,17 @@ +class BlobEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :path, :name, :mode + + expose :last_commit do |blob| + request.project.repository.last_commit_for_path(blob.commit_id, blob.path) + end + + expose :icon do |blob| + IconsHelper.file_type_icon_class('file', blob.mode, blob.name) + end + + expose :url do |blob| + project_blob_path(request.project, File.join(request.ref, blob.path)) + end +end diff --git a/app/serializers/submodule_entity.rb b/app/serializers/submodule_entity.rb new file mode 100644 index 00000000000..9a7eb5e7880 --- /dev/null +++ b/app/serializers/submodule_entity.rb @@ -0,0 +1,23 @@ +class SubmoduleEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :path, :name, :mode + + expose :icon do |blob| + 'archive' + end + + expose :project_url do |blob| + submodule_links(blob, request).first + end + + expose :tree_url do |blob| + submodule_links(blob, request).last + end + + private + + def submodule_links(blob, request) + @submodule_links ||= SubmoduleHelper.submodule_links(blob, request.ref, request.repository) + end +end diff --git a/app/serializers/tree_entity.rb b/app/serializers/tree_entity.rb new file mode 100644 index 00000000000..555e5cf83bd --- /dev/null +++ b/app/serializers/tree_entity.rb @@ -0,0 +1,17 @@ +class TreeEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :path, :name, :mode + + expose :last_commit do |tree| + request.project.repository.last_commit_for_path(tree.commit_id, tree.path) + end + + expose :icon do |tree| + IconsHelper.file_type_icon_class('folder', tree.mode, tree.name) + end + + expose :url do |tree| + project_tree_path(request.project, File.join(request.ref, tree.path)) + end +end diff --git a/app/serializers/tree_root_entity.rb b/app/serializers/tree_root_entity.rb new file mode 100644 index 00000000000..23b65aa4a4c --- /dev/null +++ b/app/serializers/tree_root_entity.rb @@ -0,0 +1,8 @@ +# TODO: Inherit from TreeEntity, when `Tree` implements `id` and `name` like `Gitlab::Git::Tree`. +class TreeRootEntity < Grape::Entity + expose :path + + expose :trees, using: TreeEntity + expose :blobs, using: BlobEntity + expose :submodules, using: SubmoduleEntity +end diff --git a/app/serializers/tree_serializer.rb b/app/serializers/tree_serializer.rb new file mode 100644 index 00000000000..713ade23bc9 --- /dev/null +++ b/app/serializers/tree_serializer.rb @@ -0,0 +1,3 @@ +class TreeSerializer < BaseSerializer + entity TreeRootEntity +end diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index bc3293fd100..3bed9c091a8 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -74,8 +74,7 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn on new navigation", profile_preferences_path(anchor: "new-navigation") + = render 'shared/user_dropdown_experimental_features' %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" diff --git a/app/views/layouts/header/_new.html.haml b/app/views/layouts/header/_new.html.haml index 60940dba475..40c86131494 100644 --- a/app/views/layouts/header/_new.html.haml +++ b/app/views/layouts/header/_new.html.haml @@ -68,8 +68,7 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn off new navigation", profile_preferences_path(anchor: "new-navigation") + = render 'shared/user_dropdown_experimental_features' %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 9aed498a8a0..0f9fe60852c 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -18,6 +18,8 @@ = scheme.name .col-sm-12 %hr + %h3#experimental-features Experimental features + %hr .col-lg-4.profile-settings-sidebar#new-navigation %h4.prepend-top-0 New Navigation @@ -40,6 +42,28 @@ New .col-sm-12 %hr + .col-lg-4.profile-settings-sidebar#new-navigation + %h4.prepend-top-0 + New Repository + %p + This setting allows you to turn on or off the new upcoming repository concept. + .col-lg-8.syntax-theme + .nav-wip + %p + The new repository is currently a work-in-progress concept and only usable on wide-screens. There are a number of improvements that we are working on in order to further refine the repository view. + %p + %a{ href: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/31890', target: 'blank' } Learn more + about the improvements that are coming soon! + = label_tag do + .preview= image_tag "old_repo.png" + %input.js-experiment-feature-toggle{ type: "radio", value: "false", name: "new_repo", checked: !show_new_repo? } + Old + = label_tag do + .preview= image_tag "new_repo.png" + %input.js-experiment-feature-toggle{ type: "radio", value: "true", name: "new_repo", checked: show_new_repo? } + New + .col-sm-12 + %hr .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Behavior diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 426085b3e1c..2f73216ac68 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -1,14 +1,12 @@ - commit = local_assigns.fetch(:commit) { @repository.commit } - ref = local_assigns.fetch(:ref) { current_ref } - project = local_assigns.fetch(:project) { @project } + #tree-holder.tree-holder.clearfix .nav-block = render 'projects/tree/tree_header', tree: @tree - - if commit - .info-well.hidden-xs.project-last-commit.append-bottom-default - .well-segment - %ul.blob-commit-info - = render 'projects/commits/commit', commit: commit, ref: ref, project: project + - if !show_new_repo? && commit + = render 'shared/commit_well', commit: commit, ref: ref, project: project = render 'projects/tree/tree_content', tree: @tree diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 8bd336269ff..facadddcd9e 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -8,6 +8,7 @@ = render "projects/blob/auxiliary_viewer", blob: blob #blob-content-holder.blob-content-holder - %article.file-holder - = render "projects/blob/header", blob: blob - = render 'projects/blob/content', blob: blob + - if !show_new_repo? + %article.file-holder + = render 'projects/blob/header', blob: blob + = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml index 013f1c267c8..cc85e5de40f 100644 --- a/app/views/projects/blob/_viewer.html.haml +++ b/app/views/projects/blob/_viewer.html.haml @@ -17,3 +17,4 @@ - viewer = BlobViewer::Download.new(viewer.blob) if viewer.binary_detected_after_load? = render viewer.partial_path, viewer: viewer + diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 7dd834e84b5..ae3fbc93633 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -5,7 +5,8 @@ = render "projects/commits/head" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('blob') + = webpack_bundle_tag 'blob' + = webpack_bundle_tag 'common_vue' = render 'projects/last_push' diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index a9b39cedb1d..370516ca55e 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -5,6 +5,11 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") +- if show_new_repo? + - content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'repo' + = render partial: 'flash_messages', locals: { project: @project } = render "projects/head" diff --git a/app/views/projects/tree/_old_tree_content.html.haml b/app/views/projects/tree/_old_tree_content.html.haml new file mode 100644 index 00000000000..820b947804e --- /dev/null +++ b/app/views/projects/tree/_old_tree_content.html.haml @@ -0,0 +1,24 @@ +.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } + .table-holder + %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } + %thead + %tr + %th= s_('ProjectFileTree|Name') + %th.hidden-xs + .pull-left= _('Last commit') + %th.text-right= _('Last Update') + - if @path.present? + %tr.tree-item + %td.tree-item-file-name + = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' + %td + %td.hidden-xs + + = render_tree(tree) + + - if tree.readme + = render "projects/tree/readme", readme: tree.readme + +- if can_edit_tree? + = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post + = render 'projects/blob/new_dir' diff --git a/app/views/projects/tree/_old_tree_header.html.haml b/app/views/projects/tree/_old_tree_header.html.haml new file mode 100644 index 00000000000..13705ca303b --- /dev/null +++ b/app/views/projects/tree/_old_tree_header.html.haml @@ -0,0 +1,70 @@ +%ul.breadcrumb.repo-breadcrumb + %li + = link_to project_tree_path(@project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + %li + = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) + + - if current_user + %li + - if !on_top_of_branch? + %span.btn.add-to-tree.disabled.has-tooltip{ title: _("You can only add files when you are on a branch"), data: { container: 'body' } } + = icon('plus') + - else + %span.dropdown + %a.dropdown-toggle.btn.add-to-tree{ href: '#', "data-toggle" => "dropdown", "data-target" => ".add-to-tree-dropdown" } + = icon('plus') + .add-to-tree-dropdown + %ul.dropdown-menu + - if can_edit_tree? + %li + = link_to project_new_blob_path(@project, @id) do + = icon('pencil fw') + #{ _('New file') } + %li + = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do + = icon('file fw') + #{ _('Upload file') } + %li + = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do + = icon('folder fw') + #{ _('New directory') } + - elsif can?(current_user, :fork_project, @project) + %li + - continue_params = { to: project_new_blob_path(@project, @id), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + = icon('pencil fw') + #{ _('New file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to upload a file again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + = icon('file fw') + #{ _('Upload file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to create a new directory again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + = icon('folder fw') + #{ _('New directory') } + + %li.divider + %li + = link_to new_project_branch_path(@project) do + = icon('code-fork fw') + #{ _('New branch') } + %li + = link_to new_project_tag_path(@project) do + = icon('tags fw') + #{ _('New tag') } diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 820b947804e..c51a8fdcec1 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -1,24 +1,11 @@ -.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } - .table-holder - %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } - %thead - %tr - %th= s_('ProjectFileTree|Name') - %th.hidden-xs - .pull-left= _('Last commit') - %th.text-right= _('Last Update') - - if @path.present? - %tr.tree-item - %td.tree-item-file-name - = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' - %td - %td.hidden-xs +- if show_new_repo? + = render 'shared/repo/repo', project: @project +- else + = render 'projects/tree/old_tree_content', tree: tree - = render_tree(tree) - - if tree.readme - = render "projects/tree/readme", readme: tree.readme - -- if can_edit_tree? - = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post - = render 'projects/blob/new_dir' +:javascript + // Load last commit log for each file in tree + $('#tree-slider').waitForImages(function() { + gl.utils.ajaxGet("#{escape_javascript(@logs_path)}"); + }); diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 858418ff8df..5df3d10d4eb 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,81 +1,18 @@ .tree-ref-container .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path + =icon('long-arrow-right', title: 'to target branch') + = render 'shared/target_switcher', destination: 'tree', path: @path - %ul.breadcrumb.repo-breadcrumb - %li - = link_to project_tree_path(@project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| - %li - = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) - - - if current_user - %li - - if !on_top_of_branch? - %span.btn.add-to-tree.disabled.has-tooltip{ title: _("You can only add files when you are on a branch"), data: { container: 'body' } } - = icon('plus') - - else - %span.dropdown - %a.dropdown-toggle.btn.add-to-tree{ href: '#', "data-toggle" => "dropdown", "data-target" => ".add-to-tree-dropdown" } - = icon('plus') - .add-to-tree-dropdown - %ul.dropdown-menu - - if can_edit_tree? - %li - = link_to project_new_blob_path(@project, @id) do - = icon('pencil fw') - #{ _('New file') } - %li - = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do - = icon('file fw') - #{ _('Upload file') } - %li - = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do - = icon('folder fw') - #{ _('New directory') } - - elsif can?(current_user, :fork_project, @project) - %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - = icon('pencil fw') - #{ _('New file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - = icon('file fw') - #{ _('Upload file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - = icon('folder fw') - #{ _('New directory') } - - %li.divider - %li - = link_to new_project_branch_path(@project) do - = icon('code-fork fw') - #{ _('New branch') } - %li - = link_to new_project_tag_path(@project) do - = icon('tags fw') - #{ _('New tag') } + - if !show_new_repo? + = render 'projects/tree/old_tree_header' .tree-controls - = render 'projects/find_file_link' + - if show_new_repo? + = render 'shared/repo/editable_mode' + - else + = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' - = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' + = render 'projects/find_file_link' = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index c8587245f88..c5a73e4c53e 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -5,6 +5,12 @@ - page_title @path.presence || _("Files"), @ref = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") + +- if show_new_repo? + - content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'repo' + = render "projects/commits/head" %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } diff --git a/app/views/shared/_commit_well.html.haml b/app/views/shared/_commit_well.html.haml new file mode 100644 index 00000000000..50e3d80a84d --- /dev/null +++ b/app/views/shared/_commit_well.html.haml @@ -0,0 +1,4 @@ +.info-well.hidden-xs.project-last-commit.append-bottom-default + .well-segment + %ul.blob-commit-info + = render 'projects/commits/commit', commit: commit, ref: ref, project: project diff --git a/app/views/shared/_target_switcher.html.haml b/app/views/shared/_target_switcher.html.haml new file mode 100644 index 00000000000..3672b552f10 --- /dev/null +++ b/app/views/shared/_target_switcher.html.haml @@ -0,0 +1,20 @@ +- dropdown_toggle_text = @ref || @project.default_branch += form_tag nil, method: :get, class: "project-refs-target-form" do + = hidden_field_tag :destination, destination + - if defined?(path) + = hidden_field_tag :path, path + - @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, find: ['branches']), field_name: 'ref', input_field_name: 'new-branch', submit_form_on_click: true, visit: false }, { toggle_class: "js-project-refs-dropdown" } + %ul.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } + %li + = dropdown_title _("Create a new branch") + %li + = dropdown_input _("Create a new branch") + %li + = dropdown_title _("Select existing branch"), options: {close: false} + %li + = dropdown_filter _("Search branches and tags") + = dropdown_content + = dropdown_loading diff --git a/app/views/shared/_user_dropdown_experimental_features.html.haml b/app/views/shared/_user_dropdown_experimental_features.html.haml new file mode 100644 index 00000000000..8e71407b748 --- /dev/null +++ b/app/views/shared/_user_dropdown_experimental_features.html.haml @@ -0,0 +1 @@ +%li= link_to 'Experimental features', profile_preferences_path(anchor: 'experimental-features') diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index bd66f39fa59..0a692d9653f 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -1,5 +1,5 @@ .dropdown-page-two.dropdown-new-label - = dropdown_title("Create new label", back: true) + = dropdown_title("Create new label", options: { back: true }) = dropdown_content do .dropdown-labels-error.js-label-error %input#new_label_name.default-dropdown-input{ type: "text", placeholder: "Name new label" } diff --git a/app/views/shared/repo/_editable_mode.html.haml b/app/views/shared/repo/_editable_mode.html.haml new file mode 100644 index 00000000000..7db6431e1de --- /dev/null +++ b/app/views/shared/repo/_editable_mode.html.haml @@ -0,0 +1,3 @@ +%a.btn.btn-default#editable-mode{ "href"=>"#", "@click.prevent" => "editClicked", "v-cloak" => 1, "v-if" => "isCommitable" } + %i{ ":class" => "buttonIcon" } + %span {{buttonLabel}} diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml new file mode 100644 index 00000000000..919904a81a7 --- /dev/null +++ b/app/views/shared/repo/_repo.html.haml @@ -0,0 +1 @@ +#repo{ data: { url: repo_url(project), project_name: project.name, refs_url: refs_project_path(project, format: :json), project_url: project_path(project), project_id: project.id } } diff --git a/config/webpack.config.js b/config/webpack.config.js index 2f85b89d523..2748f5f2afe 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -4,6 +4,7 @@ var fs = require('fs'); var path = require('path'); var webpack = require('webpack'); var StatsPlugin = require('stats-webpack-plugin'); +var CopyWebpackPlugin = require('copy-webpack-plugin'); var CompressionPlugin = require('compression-webpack-plugin'); var NameAllModulesPlugin = require('name-all-modules-plugin'); var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; @@ -62,6 +63,7 @@ var config = { prometheus_metrics: './prometheus_metrics', protected_branches: './protected_branches', protected_tags: './protected_tags', + repo: './repo/index.js', sidebar: './sidebar/sidebar_bundle.js', schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js', schedules_index: './pipeline_schedules/pipeline_schedules_index_bundle.js', @@ -113,7 +115,16 @@ var config = { test: /locale\/\w+\/(.*)\.js$/, loader: 'exports-loader?locales', }, - ] + { + test: /monaco-editor\/\w+\/vs\/loader\.js$/, + use: [ + { loader: 'exports-loader', options: 'l.global' }, + { loader: 'imports-loader', options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined' }, + ], + } + ], + + noParse: [/monaco-editor\/\w+\/vs\//], }, plugins: [ @@ -172,6 +183,7 @@ var config = { 'pdf_viewer', 'pipelines', 'pipelines_details', + 'repo', 'schedule_form', 'schedules_index', 'sidebar', @@ -195,6 +207,26 @@ var config = { new webpack.optimize.CommonsChunkPlugin({ names: ['main', 'locale', 'common', 'webpack_runtime'], }), + + // copy pre-compiled vendor libraries verbatim + new CopyWebpackPlugin([ + { + from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`), + to: 'monaco-editor/vs', + transform: function(content, path) { + if (/\.js$/.test(path) && !/worker/i.test(path)) { + return ( + '(function(){\n' + + 'var define = this.define, require = this.require;\n' + + 'window.define = define; window.require = require;\n' + + content + + '\n}.call(window.__monaco_context__ || (window.__monaco_context__ = {})));' + ); + } + return content; + } + } + ]), ], resolve: { diff --git a/package.json b/package.json index fd944531a6a..d1f2b356423 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js" }, "dependencies": { + "axios": "^0.16.2", "babel-core": "^6.22.1", "babel-eslint": "^7.2.1", "babel-loader": "^6.2.10", @@ -20,6 +21,7 @@ "babel-preset-stage-2": "^6.22.0", "bootstrap-sass": "^3.3.6", "compression-webpack-plugin": "^0.3.2", + "copy-webpack-plugin": "^4.0.1", "core-js": "^2.4.1", "css-loader": "^0.28.0", "d3": "^3.5.11", @@ -30,6 +32,7 @@ "eslint-plugin-html": "^2.0.1", "exports-loader": "^0.6.4", "file-loader": "^0.11.1", + "imports-loader": "^0.7.1", "jed": "^1.1.1", "jquery": "^2.2.1", "jquery-ujs": "^1.2.1", @@ -37,6 +40,7 @@ "jszip": "^3.1.3", "jszip-utils": "^0.0.2", "marked": "^0.3.6", + "monaco-editor": "0.8.3", "mousetrap": "^1.4.6", "name-all-modules-plugin": "^1.0.1", "pdfjs-dist": "^1.8.252", diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 02bbc48dc59..a90ad60e6a8 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -35,6 +35,26 @@ describe Projects::BlobController do end end + context 'with file path and JSON format' do + context "valid branch, valid file" do + let(:id) { 'master/README.md' } + + before do + get(:show, + namespace_id: project.namespace, + project_id: project, + id: id, + format: :json) + end + + it do + expect(response).to be_ok + expect(json_response).to have_key 'html' + expect(json_response).to have_key 'plain' + end + end + end + context 'with tree path' do before do get(:show, diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js index af04e7c1e72..cfa6650d85f 100644 --- a/spec/javascripts/blob/viewer/index_spec.js +++ b/spec/javascripts/blob/viewer/index_spec.js @@ -3,10 +3,10 @@ import BlobViewer from '~/blob/viewer/index'; describe('Blob viewer', () => { let blob; - preloadFixtures('blob/show.html.raw'); + preloadFixtures('snippets/show.html.raw'); beforeEach(() => { - loadFixtures('blob/show.html.raw'); + loadFixtures('snippets/show.html.raw'); $('#modal-upload-blob').remove(); blob = new BlobViewer(); diff --git a/spec/javascripts/fixtures/blob.rb b/spec/javascripts/fixtures/snippet.rb index 16490ad5039..cc825c82190 100644 --- a/spec/javascripts/fixtures/blob.rb +++ b/spec/javascripts/fixtures/snippet.rb @@ -1,27 +1,25 @@ require 'spec_helper' -describe Projects::BlobController, '(JavaScript fixtures)', type: :controller do +describe SnippetsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } + let(:snippet) { create(:personal_snippet, title: 'snippet.md', content: '# snippet', file_name: 'snippet.md', author: admin) } render_views before(:all) do - clean_frontend_fixtures('blob/') + clean_frontend_fixtures('snippets/') end before(:each) do sign_in(admin) end - it 'blob/show.html.raw' do |example| - get(:show, - namespace_id: project.namespace, - project_id: project, - id: 'add-ipython-files/files/ipython/basic.ipynb') + it 'snippets/show.html.raw' do |example| + get(:show, id: snippet.to_param) expect(response).to be_success store_frontend_fixture(response, example.description) diff --git a/spec/javascripts/helpers/scroll_helper_spec.js b/spec/javascripts/helpers/scroll_helper_spec.js new file mode 100644 index 00000000000..16daaad68a7 --- /dev/null +++ b/spec/javascripts/helpers/scroll_helper_spec.js @@ -0,0 +1,59 @@ +import $ from 'jquery'; +import ScrollHelper from '~/helpers/scroll_helper'; + +describe('ScrollHelper', () => { + const width = 10; + + describe('getScrollWidth', () => { + const parent = jasmine.createSpyObj('parent', ['css', 'appendTo', 'remove']); + const child = jasmine.createSpyObj('child', ['css', 'appendTo', 'get']); + let scrollWidth; + + beforeEach(() => { + spyOn($.fn, 'init').and.returnValues(parent, child); + spyOn(jasmine.Fixtures.prototype, 'cleanUp'); // disable jasmine-jquery cleanup, we dont want it but its imported in test_bundle :( + + parent.css.and.returnValue(parent); + child.css.and.returnValue(child); + child.get.and.returnValue({ + offsetWidth: width, + }); + + scrollWidth = ScrollHelper.getScrollWidth(); + }); + + it('inserts 2 nested hidden scrollable divs, calls parents outerWidth, removes parent and returns the width', () => { + const initArgs = $.fn.init.calls.allArgs(); + + expect(initArgs[0][0]).toEqual('<div>'); + expect(initArgs[1][0]).toEqual('<div>'); + expect(parent.css).toHaveBeenCalledWith({ + visibility: 'hidden', + width: 100, + overflow: 'scroll', + }); + expect(child.css).toHaveBeenCalledWith({ + width: 100, + }); + expect(child.appendTo).toHaveBeenCalledWith(parent); + expect(parent.appendTo).toHaveBeenCalledWith('body'); + expect(child.get).toHaveBeenCalledWith(0); + expect(parent.remove).toHaveBeenCalled(); + expect(scrollWidth).toEqual(100 - width); + }); + }); + + describe('setScrollWidth', () => { + it('calls getScrollWidth and sets data-scroll-width', () => { + spyOn($.fn, 'find').and.callThrough(); + spyOn($.fn, 'attr'); + spyOn(ScrollHelper, 'getScrollWidth').and.returnValue(width); + + ScrollHelper.setScrollWidth(); + + expect($.fn.find).toHaveBeenCalledWith('body'); + expect($.fn.attr).toHaveBeenCalledWith('data-scroll-width', width); + expect(ScrollHelper.getScrollWidth).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/repo/monaco_loader_spec.js b/spec/javascripts/repo/monaco_loader_spec.js new file mode 100644 index 00000000000..4e9ce7fc7fe --- /dev/null +++ b/spec/javascripts/repo/monaco_loader_spec.js @@ -0,0 +1,8 @@ +describe('MonacoLoader', () => { + it('sets __monaco_context__', () => { + const monacoContext = require('monaco-editor/dev/vs/loader'); // eslint-disable-line global-require + + expect(window.__monaco_context__) // eslint-disable-line no-underscore-dangle + .toBe(monacoContext); + }); +}); diff --git a/spec/javascripts/repo/repo_binary_viewer_spec.js b/spec/javascripts/repo/repo_binary_viewer_spec.js new file mode 100644 index 00000000000..612d87e0298 --- /dev/null +++ b/spec/javascripts/repo/repo_binary_viewer_spec.js @@ -0,0 +1,52 @@ +import Vue from 'vue'; +import Store from '~/repo/repo_store'; +import repoBinaryViewer from '~/repo/repo_binary_viewer.vue'; + +describe('RepoBinaryViewer', () => { + function createComponent() { + const RepoBinaryViewer = Vue.extend(repoBinaryViewer); + + return new RepoBinaryViewer().$mount(); + } + + it('renders an img if its png', () => { + const binaryTypes = { + png: true, + }; + const activeFile = { + name: 'name', + }; + const uri = 'uri'; + Store.binary = true; + Store.binaryTypes = binaryTypes; + Store.activeFile = activeFile; + Store.pngBlobWithDataURI = uri; + const vm = createComponent(); + const img = vm.$el.querySelector(':scope > img'); + + expect(img.src).toMatch(`/${uri}`); + expect(img.alt).toEqual(activeFile.name); + }); + + it('renders an div with content if its markdown', () => { + const binaryTypes = { + markdown: true, + }; + const activeFile = { + html: 'markdown', + }; + Store.binary = true; + Store.binaryTypes = binaryTypes; + Store.activeFile = activeFile; + const vm = createComponent(); + + expect(vm.$el.querySelector(':scope > div').innerHTML).toEqual(activeFile.html); + }); + + it('does not render if no binary', () => { + Store.binary = false; + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); +}); diff --git a/spec/javascripts/repo/repo_commit_section_spec.js b/spec/javascripts/repo/repo_commit_section_spec.js new file mode 100644 index 00000000000..919314ac065 --- /dev/null +++ b/spec/javascripts/repo/repo_commit_section_spec.js @@ -0,0 +1,129 @@ +import Vue from 'vue'; +import repoCommitSection from '~/repo/repo_commit_section.vue'; +import RepoStore from '~/repo/repo_store'; +import Api from '~/api'; + +describe('RepoCommitSection', () => { + const openedFiles = [{ + id: 0, + changed: true, + url: 'url0', + newContent: 'a', + }, { + id: 1, + changed: true, + url: 'url1', + newContent: 'b', + }, { + id: 2, + changed: false, + }]; + + function createComponent() { + const RepoCommitSection = Vue.extend(repoCommitSection); + + return new RepoCommitSection().$mount(); + } + + it('renders a commit section', () => { + RepoStore.isCommitable = true; + RepoStore.openedFiles = openedFiles; + + const vm = createComponent(); + const changedFiles = [...vm.$el.querySelectorAll('.changed-files > li')]; + const branchDropdownItems = [...vm.$el.querySelectorAll('.branch-dropdown .dropdown-menu > li')]; + const commitMessage = vm.$el.querySelector('#commit-message'); + const targetBranch = vm.$el.querySelector('#target-branch'); + const newMergeRequest = vm.$el.querySelector('.new-merge-request'); + const newMergeRequestCheckbox = newMergeRequest.querySelector('input'); + const submitCommit = vm.$el.querySelector('.submit-commit'); + + expect(vm.$el.querySelector(':scope > form')).toBeTruthy(); + expect(vm.$el.querySelector('.staged-files').textContent).toEqual('Staged files (2)'); + expect(changedFiles.length).toEqual(2); + + changedFiles.forEach((changedFile, i) => { + expect(changedFile.textContent).toEqual(openedFiles[i].url); + }); + + expect(commitMessage.tagName).toEqual('TEXTAREA'); + expect(commitMessage.name).toEqual('commit-message'); + expect(branchDropdownItems[0].textContent).toEqual('Target branch'); + expect(branchDropdownItems[1].textContent).toEqual('Create my own branch'); + expect(targetBranch.tagName).toEqual('INPUT'); + expect(targetBranch.name).toEqual('target-branch'); + expect(targetBranch.type).toEqual('text'); + expect(newMergeRequest.textContent).toMatch('Start a new merge request with these changes'); + expect(newMergeRequestCheckbox.type).toEqual('checkbox'); + expect(newMergeRequestCheckbox.id).toEqual('checkboxes-0'); + expect(newMergeRequestCheckbox.name).toEqual('checkboxes'); + expect(newMergeRequestCheckbox.value).toEqual('1'); + expect(newMergeRequestCheckbox.checked).toBeFalsy(); + expect(submitCommit.type).toEqual('submit'); + expect(submitCommit.disabled).toBeTruthy(); + expect(vm.$el.querySelector('.commit-summary').textContent).toEqual('Commit 2 Files'); + }); + + it('does not render if not isCommitable', () => { + RepoStore.isCommitable = false; + RepoStore.openedFiles = [{ + id: 0, + changed: true, + }]; + + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('does not render if no changedFiles', () => { + RepoStore.isCommitable = true; + RepoStore.openedFiles = []; + + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('shows commit submit and summary if commitMessage and spinner if submitCommitsLoading', (done) => { + const projectId = 'projectId'; + const commitMessage = 'commitMessage'; + RepoStore.isCommitable = true; + RepoStore.openedFiles = openedFiles; + RepoStore.projectId = projectId; + + const vm = createComponent(); + const commitMessageEl = vm.$el.querySelector('#commit-message'); + const submitCommit = vm.$el.querySelector('.submit-commit'); + + vm.commitMessage = commitMessage; + + Vue.nextTick(() => { + expect(commitMessageEl.value).toBe(commitMessage); + expect(submitCommit.disabled).toBeFalsy(); + + spyOn(vm, 'makeCommit').and.callThrough(); + spyOn(Api, 'commitMultiple'); + + submitCommit.click(); + + Vue.nextTick(() => { + expect(vm.makeCommit).toHaveBeenCalled(); + expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeTruthy(); + + const args = Api.commitMultiple.calls.allArgs()[0]; + const { commit_message, actions } = args[1]; + + expect(args[0]).toBe(projectId); + expect(commit_message).toBe(commitMessage); + expect(actions.length).toEqual(2); + expect(actions[0].action).toEqual('update'); + expect(actions[1].action).toEqual('update'); + expect(actions[0].content).toEqual('a'); + expect(actions[1].content).toEqual('b'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/repo/repo_editor_spec.js b/spec/javascripts/repo/repo_editor_spec.js new file mode 100644 index 00000000000..541652b3fc0 --- /dev/null +++ b/spec/javascripts/repo/repo_editor_spec.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import repoEditor from '~/repo/repo_editor.vue'; +import RepoStore from '~/repo/repo_store'; + +describe('RepoEditor', () => { + function createComponent() { + const RepoEditor = Vue.extend(repoEditor); + + return new RepoEditor().$mount(); + } + + it('renders an ide container', () => { + const monacoInstance = jasmine.createSpyObj('monacoInstance', ['onMouseUp', 'onKeyUp', 'setModel', 'updateOptions']); + const monaco = { + editor: jasmine.createSpyObj('editor', ['create']), + }; + RepoStore.monaco = monaco; + + monaco.editor.create.and.returnValue(monacoInstance); + spyOn(repoEditor.watch, 'blobRaw'); + + const vm = createComponent(); + + expect(vm.$el.id).toEqual('ide'); + }); +}); diff --git a/spec/javascripts/repo/repo_file_buttons_spec.js b/spec/javascripts/repo/repo_file_buttons_spec.js new file mode 100644 index 00000000000..882a8bf3f87 --- /dev/null +++ b/spec/javascripts/repo/repo_file_buttons_spec.js @@ -0,0 +1,94 @@ +import Vue from 'vue'; +import repoFileButtons from '~/repo/repo_file_buttons.vue'; +import RepoStore from '~/repo/repo_store'; + +describe('RepoFileButtons', () => { + function createComponent() { + const RepoFileButtons = Vue.extend(repoFileButtons); + + return new RepoFileButtons().$mount(); + } + + it('renders Raw, Blame, History, Permalink, Lock and Preview toggle', () => { + const activeFile = { + extension: 'md', + url: 'url', + }; + const activeFileLabel = 'activeFileLabel'; + RepoStore.openedFiles = new Array(1); + RepoStore.activeFile = activeFile; + RepoStore.activeFileLabel = activeFileLabel; + RepoStore.editMode = true; + + const vm = createComponent(); + const raw = vm.$el.querySelector('.raw'); + const blame = vm.$el.querySelector('.blame'); + const history = vm.$el.querySelector('.history'); + + expect(vm.$el.id).toEqual('repo-file-buttons'); + expect(vm.$el.style.borderBottom).toEqual('1px solid rgb(31, 120, 209)'); + expect(raw.href).toMatch(`/${activeFile.url}`); + expect(raw.textContent).toEqual('Raw'); + expect(blame.href).toMatch(`/${activeFile.url}`); + expect(blame.textContent).toEqual('Blame'); + expect(history.href).toMatch(`/${activeFile.url}`); + expect(history.textContent).toEqual('History'); + expect(vm.$el.querySelector('.permalink').textContent).toEqual('Permalink'); + expect(vm.$el.querySelector('.lock').textContent).toEqual('Lock'); + expect(vm.$el.querySelector('.preview').textContent).toEqual(activeFileLabel); + }); + + it('renders a white border if not editMode', () => { + const activeFile = { + extension: 'md', + url: 'url', + }; + RepoStore.openedFiles = new Array(1); + RepoStore.activeFile = activeFile; + RepoStore.editMode = false; + + const vm = createComponent(); + + expect(vm.$el.style.borderBottom).toEqual('1px solid rgb(240, 240, 240)'); + }); + + it('triggers rawPreviewToggle on preview click', () => { + const activeFile = { + extension: 'md', + url: 'url', + }; + RepoStore.openedFiles = new Array(1); + RepoStore.activeFile = activeFile; + RepoStore.editMode = true; + + const vm = createComponent(); + const preview = vm.$el.querySelector('.preview'); + + spyOn(vm, 'rawPreviewToggle'); + + preview.click(); + + expect(vm.rawPreviewToggle).toHaveBeenCalled(); + }); + + it('does not render preview toggle if not canPreview', () => { + const activeFile = { + extension: 'abcd', + url: 'url', + }; + RepoStore.openedFiles = new Array(1); + RepoStore.activeFile = activeFile; + + const vm = createComponent(); + + expect(vm.$el.querySelector('.preview')).toBeFalsy(); + }); + + it('does not render if not isMini', () => { + RepoStore.openedFiles = []; + + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); +}); diff --git a/spec/javascripts/repo/repo_file_options_spec.js b/spec/javascripts/repo/repo_file_options_spec.js new file mode 100644 index 00000000000..cb840851620 --- /dev/null +++ b/spec/javascripts/repo/repo_file_options_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import repoFileOptions from '~/repo/repo_file_options.vue'; + +describe('RepoFileOptions', () => { + const projectName = 'projectName'; + + function createComponent(propsData) { + const RepoFileOptions = Vue.extend(repoFileOptions); + + return new RepoFileOptions({ + propsData, + }).$mount(); + } + + it('renders the title and new file/folder buttons if isMini is true', () => { + const vm = createComponent({ + isMini: true, + projectName, + }); + + expect(vm.$el.classList.contains('repo-file-options')).toBeTruthy(); + expect(vm.$el.querySelector('.title').textContent).toEqual(projectName); + expect(vm.$el.querySelector('a[title="New File"]')).toBeTruthy(); + expect(vm.$el.querySelector('a[title="New Folder"]')).toBeTruthy(); + }); + + it('does not render if isMini is false', () => { + const vm = createComponent({ + isMini: false, + projectName, + }); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); +}); diff --git a/spec/javascripts/repo/repo_file_spec.js b/spec/javascripts/repo/repo_file_spec.js new file mode 100644 index 00000000000..dad13c3fe2d --- /dev/null +++ b/spec/javascripts/repo/repo_file_spec.js @@ -0,0 +1,103 @@ +import Vue from 'vue'; +import repoFile from '~/repo/repo_file.vue'; + +describe('RepoFile', () => { + const updated = 'updated'; + const file = { + icon: 'icon', + url: 'url', + name: 'name', + lastCommitMessage: 'message', + lastCommitUpdate: Date.now(), + level: 10, + }; + const activeFile = { + url: 'url', + }; + + function createComponent(propsData) { + const RepoFile = Vue.extend(repoFile); + + return new RepoFile({ + propsData, + }).$mount(); + } + + beforeEach(() => { + spyOn(repoFile.mixins[0].methods, 'timeFormated').and.returnValue(updated); + }); + + it('renders link, icon, name and last commit details', () => { + const vm = createComponent({ + file, + activeFile, + }); + const name = vm.$el.querySelector('.repo-file-name'); + + expect(vm.$el.classList.contains('active')).toBeTruthy(); + expect(vm.$el.querySelector(`.${file.icon}`).style.marginLeft).toEqual('100px'); + expect(name.title).toEqual(file.url); + expect(name.href).toMatch(`/${file.url}`); + expect(name.textContent).toEqual(file.name); + expect(vm.$el.querySelector('.commit-message').textContent).toBe(file.lastCommitMessage); + expect(vm.$el.querySelector('.commit-update').textContent).toBe(updated); + }); + + it('does render if hasFiles is true and is loading tree', () => { + const vm = createComponent({ + file, + activeFile, + loading: { + tree: true, + }, + hasFiles: true, + }); + + expect(vm.$el.innerHTML).toBeTruthy(); + }); + + it('does not render if loading tree', () => { + const vm = createComponent({ + file, + activeFile, + loading: { + tree: true, + }, + }); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('does not render commit message and datetime if mini', () => { + const vm = createComponent({ + file, + activeFile, + isMini: true, + }); + + expect(vm.$el.querySelector('.commit-message')).toBeFalsy(); + expect(vm.$el.querySelector('.commit-update')).toBeFalsy(); + }); + + it('does not set active class if file is active file', () => { + const vm = createComponent({ + file, + activeFile: {}, + }); + + expect(vm.$el.classList.contains('active')).toBeFalsy(); + }); + + it('fires linkClicked when the link is clicked', () => { + const vm = createComponent({ + file, + activeFile, + }); + + spyOn(vm, 'linkClicked'); + + vm.$el.querySelector('.repo-file-name').click(); + + expect(vm.linkClicked).toHaveBeenCalledWith(file); + }); +}); diff --git a/spec/javascripts/repo/repo_loading_file_spec.js b/spec/javascripts/repo/repo_loading_file_spec.js new file mode 100644 index 00000000000..190b9024e55 --- /dev/null +++ b/spec/javascripts/repo/repo_loading_file_spec.js @@ -0,0 +1,79 @@ +import Vue from 'vue'; +import repoLoadingFile from '~/repo/repo_loading_file.vue'; + +describe('RepoLoadingFile', () => { + function createComponent(propsData) { + const RepoLoadingFile = Vue.extend(repoLoadingFile); + + return new RepoLoadingFile({ + propsData, + }).$mount(); + } + + function assertLines(lines) { + lines.forEach((line, n) => { + const index = n + 1; + expect(line.classList.contains(`line-of-code-${index}`)).toBeTruthy(); + }); + } + + function assertColumns(columns) { + columns.forEach((column) => { + const container = column.querySelector('.animation-container'); + const lines = [...container.querySelectorAll(':scope > div')]; + + expect(container).toBeTruthy(); + expect(lines.length).toEqual(6); + assertLines(lines); + }); + } + + it('renders 3 columns of animated LoC', () => { + const vm = createComponent({ + loading: { + tree: true, + }, + hasFiles: false, + }); + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(3); + assertColumns(columns); + }); + + it('renders 1 column of animated LoC if isMini', () => { + const vm = createComponent({ + loading: { + tree: true, + }, + hasFiles: false, + isMini: true, + }); + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(1); + assertColumns(columns); + }); + + it('does not render if tree is not loading', () => { + const vm = createComponent({ + loading: { + tree: false, + }, + hasFiles: false, + }); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('does not render if hasFiles is true', () => { + const vm = createComponent({ + loading: { + tree: true, + }, + hasFiles: true, + }); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); +}); diff --git a/spec/javascripts/repo/repo_prev_directory_spec.js b/spec/javascripts/repo/repo_prev_directory_spec.js new file mode 100644 index 00000000000..7a8487c102b --- /dev/null +++ b/spec/javascripts/repo/repo_prev_directory_spec.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import repoPrevDirectory from '~/repo/repo_prev_directory.vue'; + +describe('RepoPrevDirectory', () => { + function createComponent(propsData) { + const RepoPrevDirectory = Vue.extend(repoPrevDirectory); + + return new RepoPrevDirectory({ + propsData, + }).$mount(); + } + + it('renders a prev dir link', () => { + const prevUrl = 'prevUrl'; + const vm = createComponent({ + prevUrl, + }); + const link = vm.$el.querySelector('a'); + + spyOn(vm, 'linkClicked'); + + expect(link.href).toMatch(`/${prevUrl}`); + expect(link.textContent).toEqual('..'); + + link.click(); + + expect(vm.linkClicked).toHaveBeenCalledWith(prevUrl); + }); +}); diff --git a/spec/javascripts/repo/repo_service_spec.js b/spec/javascripts/repo/repo_service_spec.js new file mode 100644 index 00000000000..fde00056b5c --- /dev/null +++ b/spec/javascripts/repo/repo_service_spec.js @@ -0,0 +1,121 @@ +import axios from 'axios'; +import RepoService from '~/repo/repo_service'; + +describe('RepoService', () => { + it('has default json format param', () => { + expect(RepoService.options.params.format).toBe('json'); + }); + + describe('buildParams', () => { + let newParams; + const url = 'url'; + + beforeEach(() => { + newParams = {}; + + spyOn(Object, 'assign').and.returnValue(newParams); + }); + + it('clones params', () => { + const params = RepoService.buildParams(url); + + expect(Object.assign).toHaveBeenCalledWith({}, RepoService.options.params); + + expect(params).toBe(newParams); + }); + + it('sets and returns viewer params to richif urlIsRichBlob is true', () => { + spyOn(RepoService, 'urlIsRichBlob').and.returnValue(true); + + const params = RepoService.buildParams(url); + + expect(params.viewer).toEqual('rich'); + }); + + it('returns params urlIsRichBlob is false', () => { + spyOn(RepoService, 'urlIsRichBlob').and.returnValue(false); + + const params = RepoService.buildParams(url); + + expect(params.viewer).toBeUndefined(); + }); + + it('calls urlIsRichBlob with the objects url prop if no url arg is provided', () => { + spyOn(RepoService, 'urlIsRichBlob'); + RepoService.url = url; + + RepoService.buildParams(); + + expect(RepoService.urlIsRichBlob).toHaveBeenCalledWith(url); + }); + }); + + describe('urlIsRichBlob', () => { + it('returns true for md extension', () => { + const isRichBlob = RepoService.urlIsRichBlob('url.md'); + + expect(isRichBlob).toBeTruthy(); + }); + + it('returns false for js extension', () => { + const isRichBlob = RepoService.urlIsRichBlob('url.js'); + + expect(isRichBlob).toBeFalsy(); + }); + }); + + describe('getContent', () => { + const params = {}; + const url = 'url'; + const requestPromise = Promise.resolve(); + + beforeEach(() => { + spyOn(RepoService, 'buildParams').and.returnValue(params); + spyOn(axios, 'get').and.returnValue(requestPromise); + }); + + it('calls buildParams and axios.get', () => { + const request = RepoService.getContent(url); + + expect(RepoService.buildParams).toHaveBeenCalledWith(url); + expect(axios.get).toHaveBeenCalledWith(url, { + params, + }); + expect(request).toBe(requestPromise); + }); + + it('uses object url prop if no url arg is provided', () => { + RepoService.url = url; + + RepoService.getContent(); + + expect(axios.get).toHaveBeenCalledWith(url, { + params, + }); + }); + }); + + describe('getBase64Content', () => { + const url = 'url'; + const response = { data: 'data' }; + + beforeEach(() => { + spyOn(RepoService, 'bufferToBase64'); + spyOn(axios, 'get').and.returnValue(Promise.resolve(response)); + }); + + it('calls axios.get and bufferToBase64 on completion', (done) => { + const request = RepoService.getBase64Content(url); + + expect(axios.get).toHaveBeenCalledWith(url, { + responseType: 'arraybuffer', + }); + expect(request).toEqual(jasmine.any(Promise)); + + request.then(() => { + expect(RepoService.bufferToBase64).toHaveBeenCalledWith(response.data); + done(); + }).catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/repo/repo_sidebar_spec.js b/spec/javascripts/repo/repo_sidebar_spec.js new file mode 100644 index 00000000000..07b3b403976 --- /dev/null +++ b/spec/javascripts/repo/repo_sidebar_spec.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import RepoStore from '~/repo/repo_store'; +import repoSidebar from '~/repo/repo_sidebar.vue'; + +describe('RepoSidebar', () => { + function createComponent() { + const RepoSidebar = Vue.extend(repoSidebar); + + return new RepoSidebar().$mount(); + } + + it('renders a sidebar', () => { + RepoStore.files = [{ + id: 0, + }]; + const vm = createComponent(); + const thead = vm.$el.querySelector('thead'); + const tbody = vm.$el.querySelector('tbody'); + + expect(vm.$el.id).toEqual('sidebar'); + expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy(); + expect(thead.querySelector('.name').textContent).toEqual('Name'); + expect(thead.querySelector('.last-commit').textContent).toEqual('Last Commit'); + expect(thead.querySelector('.last-update').textContent).toEqual('Last Update'); + expect(tbody.querySelector('.repo-file-options')).toBeFalsy(); + expect(tbody.querySelector('.prev-directory')).toBeFalsy(); + expect(tbody.querySelector('.loading-file')).toBeFalsy(); + expect(tbody.querySelector('.file')).toBeTruthy(); + }); + + it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', () => { + RepoStore.openedFiles = [{ + id: 0, + }]; + const vm = createComponent(); + + expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy(); + expect(vm.$el.querySelector('thead')).toBeFalsy(); + expect(vm.$el.querySelector('tbody .repo-file-options')).toBeTruthy(); + }); + + it('renders 5 loading files if tree is loading and not hasFiles', () => { + RepoStore.loading = { + tree: true, + }; + RepoStore.files = []; + const vm = createComponent(); + + expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5); + }); + + it('renders a prev directory if isRoot', () => { + RepoStore.files = [{ + id: 0, + }]; + RepoStore.isRoot = true; + const vm = createComponent(); + + expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy(); + }); +}); diff --git a/spec/javascripts/repo/repo_tab_spec.js b/spec/javascripts/repo/repo_tab_spec.js new file mode 100644 index 00000000000..97ad412e620 --- /dev/null +++ b/spec/javascripts/repo/repo_tab_spec.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import repoTab from '~/repo/repo_tab.vue'; + +describe('RepoTab', () => { + function createComponent(propsData) { + const RepoTab = Vue.extend(repoTab); + + return new RepoTab({ + propsData, + }).$mount(); + } + + it('renders a close link and a name link', () => { + const tab = { + loading: false, + url: 'url', + name: 'name', + }; + const vm = createComponent({ + tab, + }); + const close = vm.$el.querySelector('.close'); + const name = vm.$el.querySelector(`a[title="${tab.url}"]`); + + spyOn(vm, 'xClicked'); + spyOn(vm, 'tabClicked'); + + expect(close.querySelector('.fa-times')).toBeTruthy(); + expect(name.textContent).toEqual(tab.name); + + close.click(); + name.click(); + + expect(vm.xClicked).toHaveBeenCalledWith(tab); + expect(vm.tabClicked).toHaveBeenCalledWith(tab); + }); + + it('renders a spinner if tab is loading', () => { + const tab = { + loading: true, + url: 'url', + }; + const vm = createComponent({ + tab, + }); + const close = vm.$el.querySelector('.close'); + const name = vm.$el.querySelector(`a[title="${tab.url}"]`); + + expect(close).toBeFalsy(); + expect(name).toBeFalsy(); + expect(vm.$el.querySelector('.fa.fa-spinner.fa-spin')).toBeTruthy(); + }); + + it('renders an fa-circle icon if tab is changed', () => { + const tab = { + loading: false, + url: 'url', + name: 'name', + changed: true, + }; + const vm = createComponent({ + tab, + }); + + expect(vm.$el.querySelector('.close .fa-circle')).toBeTruthy(); + }); +}); diff --git a/spec/javascripts/repo/repo_tabs_spec.js b/spec/javascripts/repo/repo_tabs_spec.js new file mode 100644 index 00000000000..6bf4f0f4498 --- /dev/null +++ b/spec/javascripts/repo/repo_tabs_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import RepoStore from '~/repo/repo_store'; +import repoTabs from '~/repo/repo_tabs.vue'; + +describe('RepoTabs', () => { + const openedFiles = [{ + id: 0, + active: true, + }, { + id: 1, + }]; + + function createComponent() { + const RepoTabs = Vue.extend(repoTabs); + + return new RepoTabs().$mount(); + } + + it('renders a list of tabs', () => { + RepoStore.openedFiles = openedFiles; + RepoStore.tabsOverflow = true; + + const vm = createComponent(); + const tabs = [...vm.$el.querySelectorAll(':scope > li')]; + + expect(vm.$el.id).toEqual('tabs'); + expect(vm.$el.classList.contains('overflown')).toBeTruthy(); + expect(tabs.length).toEqual(2); + expect(tabs[0].classList.contains('active')).toBeTruthy(); + expect(tabs[1].classList.contains('active')).toBeFalsy(); + }); + + it('does not render a tabs list if not isMini', () => { + RepoStore.openedFiles = []; + + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('does not apply overflown class if not tabsOverflow', () => { + RepoStore.openedFiles = openedFiles; + RepoStore.tabsOverflow = false; + + const vm = createComponent(); + + expect(vm.$el.classList.contains('overflown')).toBeFalsy(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 98da6a984d1..81d1a3a20dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -233,6 +233,13 @@ aws4@^1.2.1: version "1.6.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" +axios@^0.16.2: + version "0.16.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.16.2.tgz#ba4f92f17167dfbab40983785454b9ac149c3c6d" + dependencies: + follow-redirects "^1.2.3" + is-buffer "^1.1.5" + babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.22.0: version "6.22.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.22.0.tgz#027620bee567a88c32561574e7fd0801d33118e4" @@ -887,6 +894,10 @@ block-stream@*: dependencies: inherits "~2.0.0" +bluebird@^2.10.2: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" + bluebird@^3.0.5, bluebird@^3.1.1, bluebird@^3.3.0: version "3.4.7" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" @@ -1363,6 +1374,19 @@ cookie@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" +copy-webpack-plugin@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.0.1.tgz#9728e383b94316050d0c7463958f2b85c0aa8200" + dependencies: + bluebird "^2.10.2" + fs-extra "^0.26.4" + glob "^6.0.4" + is-glob "^3.1.0" + loader-utils "^0.2.15" + lodash "^4.3.0" + minimatch "^3.0.0" + node-dir "^0.1.10" + core-js@^2.2.0, core-js@^2.4.0, core-js@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" @@ -1523,7 +1547,13 @@ d3@^3.5.11: version "3.5.11" resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.11.tgz#d130750eed0554db70e8432102f920a12407b69c" -d@^0.1.1, d@~0.1.1: +d@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" + dependencies: + es5-ext "^0.10.9" + +d@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" dependencies: @@ -1561,7 +1591,7 @@ debug@2.6.7: dependencies: ms "2.0.0" -debug@^2.1.0, debug@^2.1.1, debug@^2.2.0: +debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.4.5: version "2.6.0" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.0.tgz#bc596bcabe7617f11d9fa15361eded5608b8499b" dependencies: @@ -1865,52 +1895,52 @@ error-ex@^1.2.0: dependencies: is-arrayish "^0.2.1" -es5-ext@^0.10.7, es5-ext@^0.10.8, es5-ext@~0.10.11, es5-ext@~0.10.2, es5-ext@~0.10.7: - version "0.10.12" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.12.tgz#aa84641d4db76b62abba5e45fd805ecbab140047" +es5-ext@^0.10.14, es5-ext@^0.10.8, es5-ext@^0.10.9, es5-ext@~0.10.14, es5-ext@~0.10.2: + version "0.10.24" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.24.tgz#a55877c9924bc0c8d9bd3c2cbe17495ac1709b14" dependencies: es6-iterator "2" es6-symbol "~3.1" -es6-iterator@2: - version "2.0.0" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.0.tgz#bd968567d61635e33c0b80727613c9cb4b096bac" +es6-iterator@2, es6-iterator@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.1.tgz#8e319c9f0453bf575d374940a655920e59ca5512" dependencies: - d "^0.1.1" - es5-ext "^0.10.7" - es6-symbol "3" + d "1" + es5-ext "^0.10.14" + es6-symbol "^3.1" es6-map@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.4.tgz#a34b147be224773a4d7da8072794cefa3632b897" + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0" dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - es6-iterator "2" - es6-set "~0.1.3" - es6-symbol "~3.1.0" - event-emitter "~0.3.4" + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-set "~0.1.5" + es6-symbol "~3.1.1" + event-emitter "~0.3.5" es6-promise@^3.0.2, es6-promise@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.0.2.tgz#010d5858423a5f118979665f46486a95c6ee2bb6" -es6-set@~0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.4.tgz#9516b6761c2964b92ff479456233a247dc707ce8" +es6-set@~0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - es6-iterator "2" - es6-symbol "3" - event-emitter "~0.3.4" + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-symbol "3.1.1" + event-emitter "~0.3.5" -es6-symbol@3, es6-symbol@~3.1, es6-symbol@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.0.tgz#94481c655e7a7cad82eba832d97d5433496d7ffa" +es6-symbol@3, es6-symbol@3.1.1, es6-symbol@^3.1, es6-symbol@~3.1, es6-symbol@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" dependencies: - d "~0.1.1" - es5-ext "~0.10.11" + d "1" + es5-ext "~0.10.14" es6-weak-map@^2.0.1: version "2.0.1" @@ -2106,12 +2136,12 @@ eve-raphael@0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30" -event-emitter@~0.3.4: - version "0.3.4" - resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.4.tgz#8d63ddfb4cfe1fae3b32ca265c4c720222080bb5" +event-emitter@~0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" dependencies: - d "~0.1.1" - es5-ext "~0.10.7" + d "1" + es5-ext "~0.10.14" event-stream@~3.3.0: version "3.3.4" @@ -2355,6 +2385,12 @@ flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" +follow-redirects@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.2.3.tgz#01abaeca85e3609837d9fcda3167a7e42fdaca21" + dependencies: + debug "^2.4.5" + for-in@^0.1.5: version "0.1.6" resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.6.tgz#c9f96e89bfad18a545af5ec3ed352a1d9e5b4dc8" @@ -2395,6 +2431,16 @@ fs-access@^1.0.0: dependencies: null-check "^1.0.0" +fs-extra@^0.26.4: + version "0.26.7" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.26.7.tgz#9ae1fdd94897798edab76d0918cf42d0c3184fa9" + dependencies: + graceful-fs "^4.1.2" + jsonfile "^2.1.0" + klaw "^1.0.0" + path-is-absolute "^1.0.0" + rimraf "^2.2.8" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -2488,6 +2534,16 @@ glob@^5.0.15: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^6.0.4: + version "6.0.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" @@ -2770,6 +2826,13 @@ immediate@~3.0.5: version "3.0.6" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" +imports-loader@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-0.7.1.tgz#f204b5f34702a32c1db7d48d89d5e867a0441253" + dependencies: + loader-utils "^1.0.2" + source-map "^0.5.6" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -2862,9 +2925,9 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" -is-buffer@^1.0.2: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.4.tgz#cfc86ccd5dc5a52fa80489111c6920c457e2d98b" +is-buffer@^1.0.2, is-buffer@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc" is-builtin-module@^1.0.0: version "1.0.0" @@ -3246,6 +3309,12 @@ json5@^0.5.0, json5@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" +jsonfile@^2.1.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" + optionalDependencies: + graceful-fs "^4.1.6" + jsonify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" @@ -3353,6 +3422,12 @@ kind-of@^3.0.2: dependencies: is-buffer "^1.0.2" +klaw@^1.0.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439" + optionalDependencies: + graceful-fs "^4.1.9" + latest-version@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-1.0.1.tgz#72cfc46e3e8d1be651e1ebb54ea9f6ea96f374bb" @@ -3396,7 +3471,7 @@ loader-runner@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" -loader-utils@^0.2.11, loader-utils@^0.2.16, loader-utils@^0.2.5: +loader-utils@^0.2.11, loader-utils@^0.2.15, loader-utils@^0.2.16, loader-utils@^0.2.5: version "0.2.16" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.16.tgz#f08632066ed8282835dff88dfb52704765adee6d" dependencies: @@ -3723,6 +3798,10 @@ moment@2.x: version "2.17.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.1.tgz#fed9506063f36b10f066c8b59a144d7faebe1d82" +monaco-editor@0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.8.3.tgz#523bdf2d1524db2c2dfc3cae0a7b6edc48d6dea6" + mousetrap@^1.4.6: version "1.4.6" resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.4.6.tgz#eaca72e22e56d5b769b7555873b688c3332e390a" @@ -3765,6 +3844,12 @@ nested-error-stacks@^1.0.0: dependencies: inherits "~2.0.1" +node-dir@^0.1.10: + version "0.1.17" + resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" + dependencies: + minimatch "^3.0.2" + node-ensure@^0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/node-ensure/-/node-ensure-0.0.0.tgz#ecae764150de99861ec5c810fd5d096b183932a7" |