diff options
Diffstat (limited to 'app/assets/javascripts/repo')
37 files changed, 1740 insertions, 1246 deletions
diff --git a/app/assets/javascripts/repo/components/new_branch_form.vue b/app/assets/javascripts/repo/components/new_branch_form.vue new file mode 100644 index 00000000000..ba7090e4a9d --- /dev/null +++ b/app/assets/javascripts/repo/components/new_branch_form.vue @@ -0,0 +1,108 @@ +<script> + import { mapState, mapActions } from 'vuex'; + import flash, { hideFlash } from '../../flash'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + + export default { + components: { + loadingIcon, + }, + data() { + return { + branchName: '', + loading: false, + }; + }, + computed: { + ...mapState([ + 'currentBranch', + ]), + btnDisabled() { + return this.loading || this.branchName === ''; + }, + }, + methods: { + ...mapActions([ + 'createNewBranch', + ]), + toggleDropdown() { + this.$dropdown.dropdown('toggle'); + }, + submitNewBranch() { + // need to query as the element is appended outside of Vue + const flashEl = this.$refs.flashContainer.querySelector('.flash-alert'); + + this.loading = true; + + if (flashEl) { + hideFlash(flashEl, false); + } + + this.createNewBranch(this.branchName) + .then(() => { + this.loading = false; + this.branchName = ''; + + if (this.dropdownText) { + this.dropdownText.textContent = this.currentBranch; + } + + this.toggleDropdown(); + }) + .catch(res => res.json().then((data) => { + this.loading = false; + flash(data.message, 'alert', this.$el); + })); + }, + }, + created() { + // Dropdown is outside of Vue instance & is controlled by Bootstrap + this.$dropdown = $('.git-revision-dropdown'); + + // text element is outside Vue app + this.dropdownText = document.querySelector('.project-refs-form .dropdown-toggle-text'); + }, + }; +</script> + +<template> + <div> + <div + class="flash-container" + ref="flashContainer" + > + </div> + <p> + Create from: + <code>{{ currentBranch }}</code> + </p> + <input + class="form-control js-new-branch-name" + type="text" + placeholder="Name new branch" + v-model="branchName" + @keyup.enter.stop.prevent="submitNewBranch" + /> + <div class="prepend-top-default clearfix"> + <button + type="button" + class="btn btn-primary pull-left" + :disabled="btnDisabled" + @click.stop.prevent="submitNewBranch" + > + <loading-icon + v-if="loading" + :inline="true" + /> + <span>Create</span> + </button> + <button + type="button" + class="btn btn-default pull-right" + @click.stop.prevent="toggleDropdown" + > + Cancel + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/new_dropdown/index.vue b/app/assets/javascripts/repo/components/new_dropdown/index.vue new file mode 100644 index 00000000000..a5ee4f71281 --- /dev/null +++ b/app/assets/javascripts/repo/components/new_dropdown/index.vue @@ -0,0 +1,84 @@ +<script> + import { mapState } from 'vuex'; + import newModal from './modal.vue'; + import upload from './upload.vue'; + + export default { + components: { + newModal, + upload, + }, + data() { + return { + openModal: false, + modalType: '', + }; + }, + computed: { + ...mapState([ + 'path', + ]), + }, + methods: { + createNewItem(type) { + this.modalType = type; + this.toggleModalOpen(); + }, + toggleModalOpen() { + this.openModal = !this.openModal; + }, + }, + }; +</script> + +<template> + <div> + <ul class="breadcrumb repo-breadcrumb"> + <li class="dropdown"> + <button + type="button" + class="btn btn-default dropdown-toggle add-to-tree" + data-toggle="dropdown" + aria-label="Create new file or directory" + > + <i + class="fa fa-plus" + aria-hidden="true" + > + </i> + </button> + <ul class="dropdown-menu"> + <li> + <a + href="#" + role="button" + @click.prevent="createNewItem('blob')" + > + {{ __('New file') }} + </a> + </li> + <li> + <upload + :path="path" + /> + </li> + <li> + <a + href="#" + role="button" + @click.prevent="createNewItem('tree')" + > + {{ __('New directory') }} + </a> + </li> + </ul> + </li> + </ul> + <new-modal + v-if="openModal" + :type="modalType" + :path="path" + @toggle="toggleModalOpen" + /> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/new_dropdown/modal.vue b/app/assets/javascripts/repo/components/new_dropdown/modal.vue new file mode 100644 index 00000000000..ac1f613bb71 --- /dev/null +++ b/app/assets/javascripts/repo/components/new_dropdown/modal.vue @@ -0,0 +1,98 @@ +<script> + import { mapActions } from 'vuex'; + import { __ } from '../../../locale'; + import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; + + export default { + props: { + type: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + data() { + return { + entryName: this.path !== '' ? `${this.path}/` : '', + }; + }, + components: { + popupDialog, + }, + methods: { + ...mapActions([ + 'createTempEntry', + ]), + createEntryInStore() { + this.createTempEntry({ + name: this.entryName.replace(new RegExp(`^${this.path}/`), ''), + type: this.type, + }); + + this.toggleModalOpen(); + }, + toggleModalOpen() { + this.$emit('toggle'); + }, + }, + computed: { + modalTitle() { + if (this.type === 'tree') { + return __('Create new directory'); + } + + return __('Create new file'); + }, + buttonLabel() { + if (this.type === 'tree') { + return __('Create directory'); + } + + return __('Create file'); + }, + formLabelName() { + if (this.type === 'tree') { + return __('Directory name'); + } + + return __('File name'); + }, + }, + mounted() { + this.$refs.fieldName.focus(); + }, + }; +</script> + +<template> + <popup-dialog + :title="modalTitle" + :primary-button-label="buttonLabel" + kind="success" + @toggle="toggleModalOpen" + @submit="createEntryInStore" + > + <form + class="form-horizontal" + slot="body" + @submit.prevent="createEntryInStore" + > + <fieldset class="form-group append-bottom-0"> + <label class="label-light col-sm-3"> + {{ formLabelName }} + </label> + <div class="col-sm-9"> + <input + type="text" + class="form-control" + v-model="entryName" + ref="fieldName" + /> + </div> + </fieldset> + </form> + </popup-dialog> +</template> diff --git a/app/assets/javascripts/repo/components/new_dropdown/upload.vue b/app/assets/javascripts/repo/components/new_dropdown/upload.vue new file mode 100644 index 00000000000..14ad32f4ae0 --- /dev/null +++ b/app/assets/javascripts/repo/components/new_dropdown/upload.vue @@ -0,0 +1,68 @@ +<script> + import { mapActions } from 'vuex'; + + export default { + props: { + path: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions([ + 'createTempEntry', + ]), + createFile(target, file, isText) { + const { name } = file; + let { result } = target; + + if (!isText) { + result = result.split('base64,')[1]; + } + + this.createTempEntry({ + name, + type: 'blob', + content: result, + base64: !isText, + }); + }, + readFile(file) { + const reader = new FileReader(); + const isText = file.type.match(/text.*/) !== null; + + reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true }); + + if (isText) { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + }, + openFile() { + Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file)); + }, + }, + mounted() { + this.$refs.fileUpload.addEventListener('change', this.openFile); + }, + beforeDestroy() { + this.$refs.fileUpload.removeEventListener('change', this.openFile); + }, + }; +</script> + +<template> + <label + role="button" + class="menu-item" + > + {{ __('Upload file') }} + <input + id="file-upload" + type="file" + class="hidden" + ref="fileUpload" + /> + </label> +</template> diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index d6c864cb976..98117802016 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -1,70 +1,59 @@ <script> +import { mapState, mapGetters } from 'vuex'; import RepoSidebar from './repo_sidebar.vue'; import RepoCommitSection from './repo_commit_section.vue'; import RepoTabs from './repo_tabs.vue'; import RepoFileButtons from './repo_file_buttons.vue'; import RepoPreview from './repo_preview.vue'; -import RepoMixin from '../mixins/repo_mixin'; -import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; -import Store from '../stores/repo_store'; -import Helper from '../helpers/repo_helper'; -import MonacoLoaderHelper from '../helpers/monaco_loader_helper'; +import repoEditor from './repo_editor.vue'; export default { - data: () => Store, - mixins: [RepoMixin], + computed: { + ...mapState([ + 'currentBlobView', + ]), + ...mapGetters([ + 'isCollapsed', + 'changedFiles', + ]), + }, components: { RepoSidebar, RepoTabs, RepoFileButtons, - 'repo-editor': MonacoLoaderHelper.repoEditorLoader, + repoEditor, RepoCommitSection, - PopupDialog, RepoPreview, }, - mounted() { - Helper.getContent().catch(Helper.loadingError); - }, - - methods: { - toggleDialogOpen(toggle) { - this.dialog.open = toggle; - }, - - dialogSubmitted(status) { - this.toggleDialogOpen(false); - this.dialog.status = status; - }, + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = (e) => { + if (!this.changedFiles.length) return undefined; - toggleBlobView: Store.toggleBlobView, + Object.assign(e, { + returnValue, + }); + return returnValue; + }; }, }; </script> <template> <div class="repository-view"> - <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isMini}"> + <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}"> <repo-sidebar/> - <div v-if="isMini" - class="panel-right" - :class="{'edit-mode': editMode}"> + <div + v-if="isCollapsed" + class="panel-right" + > <repo-tabs/> <component :is="currentBlobView" - class="blob-viewer-container"/> + /> <repo-file-buttons/> </div> </div> - <repo-commit-section/> - <popup-dialog - v-show="dialog.open" - :primary-button-label="__('Discard changes')" - kind="warning" - :title="__('Are you sure?')" - :body="__('Are you sure you want to discard your changes?')" - @toggle="toggleDialogOpen" - @submit="dialogSubmitted" - /> + <repo-commit-section v-if="changedFiles.length" /> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue index 1282828b504..377e3d65348 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -1,70 +1,100 @@ <script> -/* global Flash */ -import Store from '../stores/repo_store'; -import RepoMixin from '../mixins/repo_mixin'; -import Service from '../services/repo_service'; +import { mapGetters, mapState, mapActions } from 'vuex'; +import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; +import { n__ } from '../../locale'; export default { - data: () => Store, - - mixins: [RepoMixin], - + components: { + PopupDialog, + }, + data() { + return { + showNewBranchDialog: false, + submitCommitsLoading: false, + startNewMR: false, + commitMessage: '', + }; + }, computed: { - showCommitable() { - return this.isCommitable && this.changedFiles.length; - }, - - branchPaths() { - return this.changedFiles.map(f => f.path); - }, - - cantCommitYet() { + ...mapState([ + 'currentBranch', + ]), + ...mapGetters([ + 'changedFiles', + ]), + commitButtonDisabled() { return !this.commitMessage || this.submitCommitsLoading; }, - - filePluralize() { - return this.changedFiles.length > 1 ? 'files' : 'file'; + commitButtonText() { + return n__('Commit %d file', 'Commit %d files', this.changedFiles.length); }, }, - methods: { - makeCommit() { - // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions - const commitMessage = this.commitMessage; - const actions = this.changedFiles.map(f => ({ - action: 'update', - file_path: f.path, - content: f.newContent, - })); + ...mapActions([ + 'checkCommitStatus', + 'commitChanges', + 'getTreeData', + ]), + makeCommit(newBranch = false) { + const createNewBranch = newBranch || this.startNewMR; + const payload = { - branch: Store.targetBranch, - commit_message: commitMessage, - actions, + branch: createNewBranch ? `${this.currentBranch}-${new Date().getTime().toString()}` : this.currentBranch, + commit_message: this.commitMessage, + actions: this.changedFiles.map(f => ({ + action: f.tempFile ? 'create' : 'update', + file_path: f.path, + content: f.content, + encoding: f.base64 ? 'base64' : 'text', + })), + start_branch: createNewBranch ? this.currentBranch : undefined, }; - Store.submitCommitsLoading = true; - Service.commitFiles(payload) - .then(this.resetCommitState) - .catch(() => Flash('An error occured while committing your changes')); + + this.showNewBranchDialog = false; + this.submitCommitsLoading = true; + + this.commitChanges({ payload, newMr: this.startNewMR }) + .then(() => { + this.submitCommitsLoading = false; + this.getTreeData(); + }) + .catch(() => { + this.submitCommitsLoading = false; + }); }, + tryCommit() { + this.submitCommitsLoading = true; - resetCommitState() { - this.submitCommitsLoading = false; - this.changedFiles = []; - this.commitMessage = ''; - this.editMode = false; - window.scrollTo(0, 0); + this.checkCommitStatus() + .then((branchChanged) => { + if (branchChanged) { + this.showNewBranchDialog = true; + } else { + this.makeCommit(); + } + }) + .catch(() => { + this.submitCommitsLoading = false; + }); }, }, }; </script> <template> -<div - v-if="showCommitable" - id="commit-area"> +<div id="commit-area"> + <popup-dialog + v-if="showNewBranchDialog" + :primary-button-label="__('Create new branch')" + kind="primary" + :title="__('Branch has changed')" + :text="__('This branch has changed since you started editing. Would you like to create a new branch?')" + @toggle="showNewBranchDialog = false" + @submit="makeCommit(true)" + /> <form class="form-horizontal" - @submit.prevent="makeCommit"> + @submit.prevent="tryCommit()"> <fieldset> <div class="form-group"> <label class="col-md-4 control-label staged-files"> @@ -73,10 +103,10 @@ export default { <div class="col-md-6"> <ul class="list-unstyled changed-files"> <li - v-for="branchPath in branchPaths" - :key="branchPath"> + v-for="(file, index) in changedFiles" + :key="index"> <span class="help-block"> - {{branchPath}} + {{ file.path }} </span> </li> </ul> @@ -105,27 +135,34 @@ export default { </label> <div class="col-md-6"> <span class="help-block"> - {{targetBranch}} + {{currentBranch}} </span> </div> </div> <div class="col-md-offset-4 col-md-6"> <button - ref="submitCommit" type="submit" - :disabled="cantCommitYet" + :disabled="commitButtonDisabled" class="btn btn-success"> <i v-if="submitCommitsLoading" - class="fa fa-spinner fa-spin" + class="js-commit-loading-icon fa fa-spinner fa-spin" aria-hidden="true" aria-label="loading"> </i> <span class="commit-summary"> - Commit {{changedFiles.length}} {{filePluralize}} + {{ commitButtonText }} </span> </button> </div> + <div class="col-md-offset-4 col-md-6"> + <div class="checkbox"> + <label> + <input type="checkbox" v-model="startNewMR"> + <span>Start a <strong>new merge request</strong> with these changes</span> + </label> + </div> + </div> </fieldset> </form> </div> diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue index 29b76975561..6c1bb4b8566 100644 --- a/app/assets/javascripts/repo/components/repo_edit_button.vue +++ b/app/assets/javascripts/repo/components/repo_edit_button.vue @@ -1,58 +1,57 @@ <script> -import Store from '../stores/repo_store'; -import RepoMixin from '../mixins/repo_mixin'; +import { mapGetters, mapActions, mapState } from 'vuex'; +import popupDialog from '../../vue_shared/components/popup_dialog.vue'; export default { - data: () => Store, - mixins: [RepoMixin], + components: { + popupDialog, + }, computed: { + ...mapState([ + 'editMode', + 'discardPopupOpen', + ]), + ...mapGetters([ + 'canEditFile', + ]), buttonLabel() { return this.editMode ? this.__('Cancel edit') : this.__('Edit'); }, - - showButton() { - return this.isCommitable && - !this.activeFile.render_error && - !this.binary && - this.openedFiles.length; - }, }, methods: { - editCancelClicked() { - if (this.changedFiles.length) { - this.dialog.open = true; - return; - } - this.editMode = !this.editMode; - Store.toggleBlobView(); - }, - toggleProjectRefsForm() { - $('.project-refs-form').toggleClass('disabled', this.editMode); - $('.js-tree-ref-target-holder').toggle(this.editMode); - }, - }, - - watch: { - editMode() { - this.toggleProjectRefsForm(); - }, + ...mapActions([ + 'toggleEditMode', + 'closeDiscardPopup', + ]), }, }; </script> <template> -<button - v-if="showButton" - class="btn btn-default" - type="button" - @click.prevent="editCancelClicked"> - <i - v-if="!editMode" - class="fa fa-pencil" - aria-hidden="true"> - </i> - <span> - {{buttonLabel}} - </span> -</button> + <div class="editable-mode"> + <button + v-if="canEditFile" + class="btn btn-default" + type="button" + @click.prevent="toggleEditMode()"> + <i + v-if="!editMode" + class="fa fa-pencil" + aria-hidden="true"> + </i> + <span> + {{buttonLabel}} + </span> + </button> + <popup-dialog + v-if="discardPopupOpen" + class="text-left" + :primary-button-label="__('Discard changes')" + kind="warning" + :title="__('Are you sure?')" + :text="__('Are you sure you want to discard your changes?')" + @toggle="closeDiscardPopup" + @submit="toggleEditMode(true)" + /> + </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue index 96d6a75bb61..1c864b176b1 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/repo/components/repo_editor.vue @@ -1,117 +1,107 @@ <script> /* global monaco */ -import Store from '../stores/repo_store'; -import Service from '../services/repo_service'; -import Helper from '../helpers/repo_helper'; - -const RepoEditor = { - data: () => Store, +import { mapGetters, mapActions } from 'vuex'; +import flash from '../../flash'; +import monacoLoader from '../monaco_loader'; +export default { destroyed() { - if (Helper.monacoInstance) { - Helper.monacoInstance.destroy(); + if (this.monacoInstance) { + this.monacoInstance.destroy(); } }, - mounted() { - Service.getRaw(this.activeFile.raw_path) - .then((rawResponse) => { - Store.blobRaw = rawResponse.data; - Store.activeFile.plain = rawResponse.data; - - const monacoInstance = Helper.monaco.editor.create(this.$el, { - model: null, - readOnly: false, - contextmenu: false, - }); - - Helper.monacoInstance = monacoInstance; - - this.addMonacoEvents(); - - this.setupEditor(); - }) - .catch(Helper.loadingError); + if (this.monaco) { + this.initMonaco(); + } else { + monacoLoader(['vs/editor/editor.main'], () => { + this.monaco = monaco; + + this.initMonaco(); + }); + } }, - methods: { - setupEditor() { - this.showHide(); + ...mapActions([ + 'getRawFileData', + 'changeFileContent', + ]), + initMonaco() { + if (this.shouldHideEditor) return; + + if (this.monacoInstance) { + this.monacoInstance.setModel(null); + } - Helper.setMonacoModelFromLanguage(); - }, + this.getRawFileData(this.activeFile) + .then(() => { + if (!this.monacoInstance) { + this.monacoInstance = this.monaco.editor.create(this.$el, { + model: null, + readOnly: false, + contextmenu: true, + scrollBeyondLastLine: false, + }); - showHide() { - if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) { - this.$el.style.display = 'none'; - } else { - this.$el.style.display = 'inline-block'; - } - }, + this.languages = this.monaco.languages.getLanguages(); - addMonacoEvents() { - Helper.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp); - Helper.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this)); - }, + this.addMonacoEvents(); + } - onMonacoEditorKeysPressed() { - Store.setActiveFileContents(Helper.monacoInstance.getValue()); + this.setupEditor(); + }) + .catch(() => flash('Error setting up monaco. Please try again.')); }, + setupEditor() { + if (!this.activeFile) return; + const content = this.activeFile.content !== '' ? this.activeFile.content : this.activeFile.raw; - onMonacoEditorMouseUp(e) { - if (!e.target.position) return; - const lineNumber = e.target.position.lineNumber; - if (e.target.element.classList.contains('line-numbers')) { - location.hash = `L${lineNumber}`; - Store.activeLine = lineNumber; + const foundLang = this.languages.find(lang => + lang.extensions && lang.extensions.indexOf(this.activeFileExtension) === 0, + ); + const newModel = this.monaco.editor.createModel( + content, foundLang ? foundLang.id : 'plaintext', + ); - Helper.monacoInstance.setPosition({ - lineNumber: this.activeLine, - column: 1, + this.monacoInstance.setModel(newModel); + }, + addMonacoEvents() { + this.monacoInstance.onKeyUp(() => { + this.changeFileContent({ + file: this.activeFile, + content: this.monacoInstance.getValue(), }); - } + }); }, }, - watch: { - dialog: { - handler(obj) { - const newObj = obj; - if (newObj.status) { - newObj.status = false; - this.openedFiles = this.openedFiles.map((file) => { - const f = file; - if (f.active) { - this.blobRaw = f.plain; - } - f.changed = false; - delete f.newContent; - - return f; - }); - this.editMode = false; - Store.toggleBlobView(); - } - }, - deep: true, - }, - - blobRaw() { - if (Helper.monacoInstance && !this.isTree) { - this.setupEditor(); + activeFile(oldVal, newVal) { + if (newVal && !newVal.active) { + this.initMonaco(); } }, }, computed: { + ...mapGetters([ + 'activeFile', + 'activeFileExtension', + ]), shouldHideEditor() { - return !this.openedFiles.length || (this.binary && !this.activeFile.raw); + return this.activeFile.binary && !this.activeFile.raw; }, }, }; - -export default RepoEditor; </script> <template> -<div id="ide" v-if='!shouldHideEditor'></div> + <div + id="ide" + class="blob-viewer-container blob-editor-container" + > + <div + v-if="shouldHideEditor" + v-html="activeFile.html" + > + </div> + </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue index 20ebf840774..7a23154b340 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/repo/components/repo_file.vue @@ -1,107 +1,95 @@ <script> -import TimeAgoMixin from '../../vue_shared/mixins/timeago'; + import { mapActions, mapGetters } from 'vuex'; + import timeAgoMixin from '../../vue_shared/mixins/timeago'; -const RepoFile = { - mixins: [TimeAgoMixin], - props: { - file: { - type: Object, - required: true, + export default { + mixins: [ + timeAgoMixin, + ], + props: { + file: { + type: Object, + required: true, + }, }, - isMini: { - type: Boolean, - required: false, - default: false, + computed: { + ...mapGetters([ + 'isCollapsed', + ]), + fileIcon() { + return { + 'fa-spinner fa-spin': this.file.loading, + [this.file.icon]: !this.file.loading, + 'fa-folder-open': !this.file.loading && this.file.opened, + }; + }, + levelIndentation() { + return { + marginLeft: `${this.file.level * 16}px`, + }; + }, + shortId() { + return this.file.id.substr(0, 8); + }, }, - loading: { - type: Object, - required: false, - default() { return { tree: false }; }, + methods: { + ...mapActions([ + 'clickedTreeRow', + ]), }, - hasFiles: { - type: Boolean, - required: false, - default: false, - }, - activeFile: { - type: Object, - required: true, - }, - }, - - computed: { - canShowFile() { - return !this.loading.tree || this.hasFiles; - }, - - fileIcon() { - const classObj = { - 'fa-spinner fa-spin': this.file.loading, - [this.file.icon]: !this.file.loading, - }; - return classObj; - }, - - fileIndentation() { - return { - 'margin-left': `${this.file.level * 10}px`, - }; - }, - - activeFileClass() { - return { - active: this.activeFile.url === this.file.url, - }; - }, - }, - - methods: { - linkClicked(file) { - this.$emit('linkclicked', file); - }, - }, -}; - -export default RepoFile; + }; </script> <template> -<tr - v-if="canShowFile" - class="file" - :class="activeFileClass" - @click.prevent="linkClicked(file)"> - <td> - <i - class="fa fa-fw file-icon" - :class="fileIcon" - :style="fileIndentation" - aria-label="file icon"> - </i> - <a - :href="file.url" - class="repo-file-name" - :title="file.url"> - {{file.name}} - </a> - </td> + <tr + class="file" + @click.prevent="clickedTreeRow(file)"> + <td> + <i + class="fa fa-fw file-icon" + :class="fileIcon" + :style="levelIndentation" + aria-hidden="true" + > + </i> + <a + :href="file.url" + class="repo-file-name" + > + {{ file.name }} + </a> + <template v-if="file.type === 'submodule' && file.id"> + @ + <span class="commit-sha"> + <a + @click.stop + :href="file.tree_url" + > + {{ shortId }} + </a> + </span> + </template> + </td> - <template v-if="!isMini"> - <td class="hidden-sm hidden-xs"> - <div class="commit-message"> - <a @click.stop :href="file.lastCommitUrl"> - {{file.lastCommitMessage}} + <template v-if="!isCollapsed"> + <td class="hidden-sm hidden-xs"> + <a + @click.stop + :href="file.lastCommit.url" + class="commit-message" + > + {{ file.lastCommit.message }} </a> - </div> - </td> + </td> - <td class="hidden-xs"> - <span - class="commit-update" - :title="tooltipTitle(file.lastCommitUpdate)"> - {{timeFormated(file.lastCommitUpdate)}} - </span> - </td> - </template> -</tr> + <td class="commit-update hidden-xs text-right"> + <span + v-if="file.lastCommit.updatedAt" + :title="tooltipTitle(file.lastCommit.updatedAt)" + > + {{ timeFormated(file.lastCommit.updatedAt) }} + </span> + </td> + </template> + </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue index e43ef366f47..dd948ee84fb 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue @@ -1,40 +1,35 @@ <script> -import Store from '../stores/repo_store'; -import Helper from '../helpers/repo_helper'; -import RepoMixin from '../mixins/repo_mixin'; - -const RepoFileButtons = { - data: () => Store, - - mixins: [RepoMixin], +import { mapGetters } from 'vuex'; +export default { computed: { - - rawDownloadButtonLabel() { - return this.binary ? 'Download' : 'Raw'; + ...mapGetters([ + 'activeFile', + ]), + showButtons() { + return this.activeFile.rawPath || + this.activeFile.blamePath || + this.activeFile.commitsPath || + this.activeFile.permalink; }, - - canPreview() { - return Helper.isRenderable(); + rawDownloadButtonLabel() { + return this.activeFile.binary ? 'Download' : 'Raw'; }, }, - - methods: { - rawPreviewToggle: Store.toggleRawPreview, - }, }; - -export default RepoFileButtons; </script> <template> - <div id="repo-file-buttons"> + <div + v-if="showButtons" + class="repo-file-buttons" + > <a - :href="activeFile.raw_path" + :href="activeFile.rawPath" target="_blank" class="btn btn-default raw" rel="noopener noreferrer"> - {{rawDownloadButtonLabel}} + {{ rawDownloadButtonLabel }} </a> <div @@ -42,12 +37,12 @@ export default RepoFileButtons; role="group" aria-label="File actions"> <a - :href="activeFile.blame_path" + :href="activeFile.blamePath" class="btn btn-default blame"> Blame </a> <a - :href="activeFile.commits_path" + :href="activeFile.commitsPath" class="btn btn-default history"> History </a> @@ -57,13 +52,5 @@ export default RepoFileButtons; Permalink </a> </div> - - <a - v-if="canPreview" - href="#" - @click.prevent="rawPreviewToggle" - class="btn btn-default preview"> - {{activeFileLabel}} - </a> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_file_options.vue b/app/assets/javascripts/repo/components/repo_file_options.vue deleted file mode 100644 index 6a15755f029..00000000000 --- a/app/assets/javascripts/repo/components/repo_file_options.vue +++ /dev/null @@ -1,25 +0,0 @@ -<script> -const RepoFileOptions = { - props: { - isMini: { - type: Boolean, - required: false, - default: false, - }, - projectName: { - type: String, - required: true, - }, - }, -}; - -export default RepoFileOptions; -</script> - -<template> - <tr v-if="isMini" class="repo-file-options"> - <td> - <span class="title">{{projectName}}</span> - </td> - </tr> -</template> diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue index bc8c64c8362..1e6c405f292 100644 --- a/app/assets/javascripts/repo/components/repo_loading_file.vue +++ b/app/assets/javascripts/repo/components/repo_loading_file.vue @@ -1,43 +1,25 @@ <script> -const RepoLoadingFile = { - props: { - loading: { - type: Object, - required: false, - default: {}, - }, - hasFiles: { - type: Boolean, - required: false, - default: false, - }, - isMini: { - type: Boolean, - required: false, - default: false, - }, - }, + import { mapGetters } from 'vuex'; - computed: { - showGhostLines() { - return this.loading.tree && !this.hasFiles; + export default { + computed: { + ...mapGetters([ + 'isCollapsed', + ]), }, - }, - - methods: { - lineOfCode(n) { - return `skeleton-line-${n}`; + methods: { + lineOfCode(n) { + return `skeleton-line-${n}`; + }, }, - }, -}; - -export default RepoLoadingFile; + }; </script> <template> <tr - v-if="showGhostLines" - class="loading-file"> + class="loading-file" + aria-label="Loading files" + > <td> <div class="animation-container animation-container-small"> @@ -48,29 +30,28 @@ export default RepoLoadingFile; </div> </div> </td> - - <td - v-if="!isMini" - class="hidden-sm hidden-xs"> - <div class="animation-container"> - <div - v-for="n in 6" - :key="n" - :class="lineOfCode(n)"> + <template v-if="!isCollapsed"> + <td + class="hidden-sm hidden-xs"> + <div class="animation-container"> + <div + v-for="n in 6" + :key="n" + :class="lineOfCode(n)"> + </div> </div> - </div> - </td> + </td> - <td - v-if="!isMini" - class="hidden-xs"> - <div class="animation-container animation-container-small"> - <div - v-for="n in 6" - :key="n" - :class="lineOfCode(n)"> + <td + class="hidden-xs"> + <div class="animation-container animation-container-small animation-container-right"> + <div + v-for="n in 6" + :key="n" + :class="lineOfCode(n)"> + </div> </div> - </div> - </td> + </td> + </template> </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue index bbdbdc61e38..a2b305bbd05 100644 --- a/app/assets/javascripts/repo/components/repo_prev_directory.vue +++ b/app/assets/javascripts/repo/components/repo_prev_directory.vue @@ -1,38 +1,34 @@ <script> -import RepoMixin from '../mixins/repo_mixin'; + import { mapGetters, mapState, mapActions } from 'vuex'; -const RepoPreviousDirectory = { - props: { - prevUrl: { - type: String, - required: true, + export default { + computed: { + ...mapState([ + 'parentTreeUrl', + ]), + ...mapGetters([ + 'isCollapsed', + ]), + colSpanCondition() { + return this.isCollapsed ? undefined : 3; + }, }, - }, - - mixins: [RepoMixin], - - computed: { - colSpanCondition() { - return this.isMini ? undefined : 3; + methods: { + ...mapActions([ + 'getTreeData', + ]), }, - }, - - methods: { - linkClicked(file) { - this.$emit('linkclicked', file); - }, - }, -}; - -export default RepoPreviousDirectory; + }; </script> <template> -<tr class="prev-directory"> - <td - :colspan="colSpanCondition" - @click.prevent="linkClicked(prevUrl)"> - <a :href="prevUrl">..</a> - </td> -</tr> + <tr class="file prev-directory"> + <td + :colspan="colSpanCondition" + class="table-cell" + @click.prevent="getTreeData({ endpoint: parentTreeUrl })" + > + <a :href="parentTreeUrl">...</a> + </td> + </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index 2200754cbef..d1883299bd9 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -1,51 +1,61 @@ <script> -import Store from '../stores/repo_store'; +/* global LineHighlighter */ +import { mapGetters } from 'vuex'; export default { - data: () => Store, - mounted() { - this.highlightFile(); - }, computed: { - html() { - return this.activeFile.html; + ...mapGetters([ + 'activeFile', + ]), + renderErrorTooLarge() { + return this.activeFile.renderError === 'too_large'; }, }, - methods: { highlightFile() { $(this.$el).find('.file-content').syntaxHighlight(); }, }, - - watch: { - html() { - this.$nextTick(() => { - this.highlightFile(); - }); - }, + mounted() { + this.highlightFile(); + this.lineHighlighter = new LineHighlighter({ + fileHolderSelector: '.blob-viewer-container', + scrollFileHolder: true, + }); + }, + updated() { + this.$nextTick(() => { + this.highlightFile(); + }); }, }; </script> <template> -<div> +<div class="blob-viewer-container"> <div - v-if="!activeFile.render_error" + v-if="!activeFile.renderError" v-html="activeFile.html"> </div> <div - v-else-if="activeFile.tooLarge" + v-else-if="activeFile.tempFile" + class="vertical-center render-error"> + <p class="text-center"> + The source could not be displayed for this temporary file. + </p> + </div> + <div + v-else-if="renderErrorTooLarge" class="vertical-center render-error"> <p class="text-center"> - The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead. + The source could not be displayed because it is too large. You can <a :href="activeFile.rawPath" download>download</a> it instead. </p> </div> <div v-else class="vertical-center render-error"> <p class="text-center"> - The source could not be displayed because a rendering error occured. You can <a :href="activeFile.raw_path">download</a> it instead. + The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.rawPath" download>download</a> it instead. </p> </div> </div> diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index 3414128526d..63c0d70f5c0 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -1,101 +1,87 @@ <script> -import Service from '../services/repo_service'; -import Helper from '../helpers/repo_helper'; -import Store from '../stores/repo_store'; +import { mapState, mapGetters, mapActions } from 'vuex'; import RepoPreviousDirectory from './repo_prev_directory.vue'; -import RepoFileOptions from './repo_file_options.vue'; import RepoFile from './repo_file.vue'; import RepoLoadingFile from './repo_loading_file.vue'; -import RepoMixin from '../mixins/repo_mixin'; export default { - mixins: [RepoMixin], components: { - 'repo-file-options': RepoFileOptions, 'repo-previous-directory': RepoPreviousDirectory, 'repo-file': RepoFile, 'repo-loading-file': RepoLoadingFile, }, - created() { - this.addPopEventListener(); + window.addEventListener('popstate', this.popHistoryState); + }, + destroyed() { + window.removeEventListener('popstate', this.popHistoryState); + }, + mounted() { + this.getTreeData(); + }, + computed: { + ...mapState([ + 'loading', + 'isRoot', + ]), + ...mapState({ + projectName(state) { + return state.project.name; + }, + }), + ...mapGetters([ + 'treeList', + 'isCollapsed', + ]), }, - - data: () => Store, - methods: { - addPopEventListener() { - window.addEventListener('popstate', () => { - if (location.href.indexOf('#') > -1) return; - this.linkClicked({ - url: location.href, - }); - }); - }, - - fileClicked(clickedFile) { - let file = clickedFile; - if (file.loading) return; - file.loading = true; - if (file.type === 'tree' && file.opened) { - file = Store.removeChildFilesOfTree(file); - file.loading = false; - } else { - Service.url = file.url; - Helper.getContent(file) - .then(() => { - file.loading = false; - Helper.scrollTabsRight(); - }) - .catch(Helper.loadingError); - } - }, - - goToPreviousDirectoryClicked(prevURL) { - Service.url = prevURL; - Helper.getContent(null) - .then(() => Helper.scrollTabsRight()) - .catch(Helper.loadingError); - }, + ...mapActions([ + 'getTreeData', + 'popHistoryState', + ]), }, }; </script> <template> -<div id="sidebar" :class="{'sidebar-mini' : isMini}"> +<div id="sidebar" :class="{'sidebar-mini' : isCollapsed}"> <table class="table"> - <thead v-if="!isMini"> + <thead> <tr> - <th class="name">Name</th> - <th class="hidden-sm hidden-xs last-commit">Last Commit</th> - <th class="hidden-xs last-update">Last Update</th> + <th + v-if="isCollapsed" + class="repo-file-options title" + > + <strong class="clgray"> + {{ projectName }} + </strong> + </th> + <template v-else> + <th class="name"> + Name + </th> + <th class="hidden-sm hidden-xs last-commit"> + Last commit + </th> + <th class="hidden-xs last-update text-right"> + Last update + </th> + </template> </tr> </thead> <tbody> - <repo-file-options - :is-mini="isMini" - :project-name="projectName" - /> <repo-previous-directory - v-if="isRoot" - :prev-url="prevURL" - @linkclicked="goToPreviousDirectoryClicked(prevURL)"/> + v-if="!isRoot && treeList.length" + /> <repo-loading-file + v-if="!treeList.length && loading" 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" + v-for="(file, index) in treeList" + :key="index" :file="file" - :is-mini="isMini" - @linkclicked="fileClicked(file)" - :is-tree="isTree" - :has-files="!!files.length" - :active-file="activeFile" /> </tbody> </table> diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue index 0d0c34ec741..da0714c368c 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/repo/components/repo_tab.vue @@ -1,7 +1,7 @@ <script> -import Store from '../stores/repo_store'; +import { mapActions } from 'vuex'; -const RepoTab = { +export default { props: { tab: { type: Object, @@ -11,53 +11,52 @@ const RepoTab = { computed: { closeLabel() { - if (this.tab.changed) { + if (this.tab.changed || this.tab.tempFile) { return `${this.tab.name} changed`; } return `Close ${this.tab.name}`; }, changedClass() { const tabChangedObj = { - 'fa-times close-icon': !this.tab.changed, - 'fa-circle unsaved-icon': this.tab.changed, + 'fa-times close-icon': !this.tab.changed && !this.tab.tempFile, + 'fa-circle unsaved-icon': this.tab.changed || this.tab.tempFile, }; return tabChangedObj; }, }, methods: { - tabClicked: Store.setActiveFiles, - - closeTab(file) { - if (file.changed) return; - this.$emit('tabclosed', file); - }, + ...mapActions([ + 'setFileActive', + 'closeFile', + ]), }, }; - -export default RepoTab; </script> <template> -<li @click="tabClicked(tab)"> - <a - href="#0" - class="close" - @click.stop.prevent="closeTab(tab)" - :aria-label="closeLabel"> - <i - class="fa" - :class="changedClass" - aria-hidden="true"> - </i> - </a> + <li + :class="{ active : tab.active }" + @click="setFileActive(tab)" + > + <button + type="button" + class="close-btn" + @click.stop.prevent="closeFile({ file: tab })" + :aria-label="closeLabel"> + <i + class="fa" + :class="changedClass" + aria-hidden="true"> + </i> + </button> - <a - href="#" - class="repo-tab" - :title="tab.url" - @click.prevent="tabClicked(tab)"> - {{tab.name}} - </a> -</li> + <a + href="#" + class="repo-tab" + :title="tab.url" + @click.prevent.stop="setFileActive(tab)"> + {{tab.name}} + </a> + </li> </template> diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue index 9c5bfc5d0cf..59beae53e8d 100644 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ b/app/assets/javascripts/repo/components/repo_tabs.vue @@ -1,36 +1,29 @@ <script> -import Store from '../stores/repo_store'; -import RepoTab from './repo_tab.vue'; -import RepoMixin from '../mixins/repo_mixin'; + import { mapState } from 'vuex'; + import RepoTab from './repo_tab.vue'; -const RepoTabs = { - mixins: [RepoMixin], - - components: { - 'repo-tab': RepoTab, - }, - - data: () => Store, - - methods: { - tabClosed(file) { - Store.removeFromOpenedFiles(file); + export default { + components: { + 'repo-tab': RepoTab, }, - }, -}; - -export default RepoTabs; + computed: { + ...mapState([ + 'openFiles', + ]), + }, + }; </script> <template> -<ul id="tabs"> - <repo-tab - v-for="tab in openedFiles" - :key="tab.id" - :tab="tab" - :class="{'active' : tab.active}" - @tabclosed="tabClosed" - /> - <li class="tabs-divider" /> -</ul> + <ul + id="tabs" + class="list-unstyled" + > + <repo-tab + v-for="tab in openFiles" + :key="tab.id" + :tab="tab" + /> + <li class="tabs-divider" /> + </ul> </template> diff --git a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js b/app/assets/javascripts/repo/helpers/monaco_loader_helper.js deleted file mode 100644 index f8729bbf585..00000000000 --- a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js +++ /dev/null @@ -1,25 +0,0 @@ -/* global monaco */ -import RepoEditor from '../components/repo_editor.vue'; -import Store from '../stores/repo_store'; -import Helper from '../helpers/repo_helper'; -import monacoLoader from '../monaco_loader'; - -function repoEditorLoader() { - Store.monacoLoading = true; - return new Promise((resolve, reject) => { - monacoLoader(['vs/editor/editor.main'], () => { - Helper.monaco = monaco; - Store.monacoLoading = false; - resolve(RepoEditor); - }, () => { - Store.monacoLoading = false; - reject(); - }); - }); -} - -const MonacoLoaderHelper = { - repoEditorLoader, -}; - -export default MonacoLoaderHelper; diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js deleted file mode 100644 index 2bd8d7eea65..00000000000 --- a/app/assets/javascripts/repo/helpers/repo_helper.js +++ /dev/null @@ -1,271 +0,0 @@ -/* global Flash */ -import Service from '../services/repo_service'; -import Store from '../stores/repo_store'; -import '../../flash'; - -const RepoHelper = { - monacoInstance: null, - - getDefaultActiveFile() { - return { - active: true, - binary: false, - extension: '', - html: '', - mime_type: '', - name: '', - 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, - - getFileExtension(fileName) { - return fileName.split('.').pop(); - }, - - getLanguageIDForFile(file, langs) { - const ext = RepoHelper.getFileExtension(file.name); - const foundLang = RepoHelper.findLanguage(ext, langs); - - return foundLang ? foundLang.id : 'plaintext'; - }, - - setMonacoModelFromLanguage() { - RepoHelper.monacoInstance.setModel(null); - const languages = RepoHelper.monaco.languages.getLanguages(); - const languageID = RepoHelper.getLanguageIDForFile(Store.activeFile, languages); - const newModel = RepoHelper.monaco.editor.createModel(Store.blobRaw, languageID); - RepoHelper.monacoInstance.setModel(newModel); - }, - - findLanguage(ext, langs) { - 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.updateHistoryEntry(file.url, file.name); - return file; - }, - - isRenderable() { - const okExts = ['md', 'svg']; - return okExts.indexOf(Store.activeFile.extension) > -1; - }, - - setBinaryDataAsBase64(file) { - Service.getBase64Content(file.raw_path) - .then((response) => { - Store.blobRaw = response; - file.base64 = response; // eslint-disable-line no-param-reassign - }) - .catch(RepoHelper.loadingError); - }, - - // when you open a directory you need to put the directory files under - // the directory... This will merge the list of the current directory and the new list. - getNewMergedList(inDirectory, currentList, newList) { - const newListSorted = newList.sort(this.compareFilesCaseInsensitive); - if (!inDirectory) return newListSorted; - const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url); - if (!indexOfFile) return newListSorted; - return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile); - }, - - // within the get new merged list this does the merging of the current list of files - // and the new list of files. The files are never "in" another directory they just - // appear like they are because of the margin. - mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) { - newList.reverse().forEach((newFile) => { - const fileIndex = indexOfFile + 1; - const file = newFile; - file.level = inDirectory.level + 1; - oldList.splice(fileIndex, 0, file); - }); - - return oldList; - }, - - compareFilesCaseInsensitive(a, b) { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (a.level > 0) return 0; - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }, - - 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) { - let file = treeOrFile; - return Service.getContent() - .then((response) => { - const data = response.data; - Store.isTree = RepoHelper.isTree(data); - if (!Store.isTree) { - if (!file) file = data; - Store.binary = data.binary; - - if (data.binary) { - // file might be undefined - RepoHelper.setBinaryDataAsBase64(data); - Store.setViewToPreview(); - } else if (!Store.isPreviewView()) { - if (!data.render_error) { - Service.getRaw(data.raw_path) - .then((rawResponse) => { - Store.blobRaw = rawResponse.data; - data.plain = rawResponse.data; - RepoHelper.setFile(data, file); - }).catch(RepoHelper.loadingError); - } - } - - if (Store.isPreviewView()) { - RepoHelper.setFile(data, file); - } - - // 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.loadingError); - }, - - setFile(data, file) { - const newFile = data; - - newFile.url = file.url; - if (newFile.render_error === 'too_large' || newFile.render_error === 'collapsed') { - newFile.tooLarge = true; - } - newFile.newContent = ''; - - Store.addToOpenedFiles(newFile); - Store.setActiveFiles(newFile); - }, - - serializeBlob(blob) { - const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob); - simpleBlob.lastCommitMessage = blob.last_commit.message; - simpleBlob.lastCommitUpdate = blob.last_commit.committed_date; - simpleBlob.loading = false; - - return simpleBlob; - }, - - serializeTree(tree) { - return RepoHelper.serializeRepoEntity('tree', tree); - }, - - serializeSubmodule(submodule) { - return RepoHelper.serializeRepoEntity('submodule', submodule); - }, - - serializeRepoEntity(type, entity) { - const { url, name, icon, last_commit } = entity; - const returnObj = { - type, - name, - url, - icon: `fa-${icon}`, - level: 0, - loading: false, - }; - - if (entity.last_commit) { - returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`; - } else { - returnObj.lastCommitUrl = ''; - } - return returnObj; - }, - - scrollTabsRight() { - // wait for the transition. 0.1 seconds. - setTimeout(() => { - const tabs = document.getElementById('tabs'); - if (!tabs) return; - tabs.scrollLeft = tabs.scrollWidth; - }, 200); - }, - - dataToListOfFiles(data) { - const { blobs, trees, submodules } = data; - return [ - ...blobs.map(blob => RepoHelper.serializeBlob(blob)), - ...trees.map(tree => RepoHelper.serializeTree(tree)), - ...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)), - ]; - }, - - genKey() { - return RepoHelper.Time.now().toFixed(3); - }, - - updateHistoryEntry(url, title) { - const history = window.history; - - RepoHelper.key = RepoHelper.genKey(); - - history.pushState({ key: RepoHelper.key }, '', url); - - if (title) { - document.title = `${title} · GitLab`; - } - }, - - findOpenedFileFromActive() { - return Store.openedFiles.find(openedFile => Store.activeFile.url === openedFile.url); - }, - - loadingError() { - Flash('Unable to load this content at this time.'); - }, -}; - -export default RepoHelper; diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js index 6c1d468e937..b6801af7fcb 100644 --- a/app/assets/javascripts/repo/index.js +++ b/app/assets/javascripts/repo/index.js @@ -1,50 +1,50 @@ -import $ from 'jquery'; import Vue from 'vue'; -import Service from './services/repo_service'; -import Store from './stores/repo_store'; +import { mapActions } from 'vuex'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; import Repo from './components/repo.vue'; import RepoEditButton from './components/repo_edit_button.vue'; +import newBranchForm from './components/new_branch_form.vue'; +import newDropdown from './components/new_dropdown/index.vue'; +import store from './stores'; import Translate from '../vue_shared/translate'; -function initDropdowns() { - $('.js-tree-ref-target-holder').hide(); -} - -function addEventsForNonVueEls() { - $(document).on('change', '.dropdown', () => { - Store.targetBranch = $('.project-refs-target-form input[name="ref"]').val(); - }); - - window.onbeforeunload = function confirmUnload(e) { - const hasChanged = Store.openedFiles - .some(file => file.changed); - if (!hasChanged) return undefined; - const event = e || window.event; - if (event) event.returnValue = 'Are you sure you want to lose unsaved changes?'; - // For Safari - return 'Are you sure you want to lose unsaved changes?'; - }; -} - -function setInitialStore(data) { - Store.service = Service; - Store.service.url = data.url; - Store.service.refsUrl = data.refsUrl; - Store.projectId = data.projectId; - Store.projectName = data.projectName; - Store.projectUrl = data.projectUrl; - Store.canCommit = data.canCommit; - Store.onTopOfBranch = data.onTopOfBranch; - Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref'); - Store.checkIsCommitable(); -} - function initRepo(el) { + if (!el) return null; + return new Vue({ el, + store, components: { repo: Repo, }, + methods: { + ...mapActions([ + 'setInitialData', + ]), + }, + created() { + const data = el.dataset; + + this.setInitialData({ + project: { + id: data.projectId, + name: data.projectName, + url: data.projectUrl, + }, + endpoints: { + rootEndpoint: data.url, + newMergeRequestUrl: data.newMergeRequestUrl, + rootUrl: data.rootUrl, + }, + canCommit: convertPermissionToBoolean(data.canCommit), + onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch), + currentRef: data.ref, + path: data.currentPath, + currentBranch: data.currentBranch, + isRoot: convertPermissionToBoolean(data.root), + isInitialRoot: convertPermissionToBoolean(data.root), + }); + }, render(createElement) { return createElement('repo'); }, @@ -54,25 +54,53 @@ function initRepo(el) { function initRepoEditButton(el) { return new Vue({ el, + store, components: { repoEditButton: RepoEditButton, }, + render(createElement) { + return createElement('repo-edit-button'); + }, }); } -function initRepoBundle() { - const repo = document.getElementById('repo'); - const editButton = document.querySelector('.editable-mode'); - setInitialStore(repo.dataset); - addEventsForNonVueEls(); - initDropdowns(); +function initNewDropdown(el) { + return new Vue({ + el, + store, + components: { + newDropdown, + }, + render(createElement) { + return createElement('new-dropdown'); + }, + }); +} - Vue.use(Translate); +function initNewBranchForm() { + const el = document.querySelector('.js-new-branch-dropdown'); - initRepo(repo); - initRepoEditButton(editButton); + if (!el) return null; + + return new Vue({ + el, + components: { + newBranchForm, + }, + store, + render(createElement) { + return createElement('new-branch-form'); + }, + }); } -$(initRepoBundle); +const repo = document.getElementById('repo'); +const editButton = document.querySelector('.editable-mode'); +const newDropdownHolder = document.querySelector('.js-new-dropdown'); + +Vue.use(Translate); -export default initRepoBundle; +initRepo(repo); +initRepoEditButton(editButton); +initNewBranchForm(); +initNewDropdown(newDropdownHolder); diff --git a/app/assets/javascripts/repo/mixins/repo_mixin.js b/app/assets/javascripts/repo/mixins/repo_mixin.js deleted file mode 100644 index c8e8238a0d3..00000000000 --- a/app/assets/javascripts/repo/mixins/repo_mixin.js +++ /dev/null @@ -1,17 +0,0 @@ -import Store from '../stores/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/services/index.js b/app/assets/javascripts/repo/services/index.js new file mode 100644 index 00000000000..dc222ccac01 --- /dev/null +++ b/app/assets/javascripts/repo/services/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import Api from '../../api'; + +Vue.use(VueResource); + +export default { + getTreeData(endpoint) { + return Vue.http.get(endpoint, { params: { format: 'json' } }); + }, + getFileData(endpoint) { + return Vue.http.get(endpoint, { params: { format: 'json' } }); + }, + getRawFileData(file) { + if (file.tempFile) { + return Promise.resolve(file.content); + } + + return Vue.http.get(file.rawPath, { params: { format: 'json' } }) + .then(res => res.text()); + }, + getBranchData(projectId, currentBranch) { + return Api.branchSingle(projectId, currentBranch); + }, + createBranch(projectId, payload) { + const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId); + + return Vue.http.post(url, payload); + }, + commit(projectId, payload) { + return Api.commitMultiple(projectId, payload); + }, +}; diff --git a/app/assets/javascripts/repo/services/repo_service.js b/app/assets/javascripts/repo/services/repo_service.js deleted file mode 100644 index af83497fa39..00000000000 --- a/app/assets/javascripts/repo/services/repo_service.js +++ /dev/null @@ -1,82 +0,0 @@ -/* global Flash */ -import axios from 'axios'; -import Store from '../stores/repo_store'; -import Api from '../../api'; -import Helper from '../helpers/repo_helper'; - -const RepoService = { - url: '', - options: { - params: { - format: 'json', - }, - }, - richExtensionRegExp: /md/, - - getRaw(url) { - return axios.get(url, { - // Stop Axios from parsing a JSON file into a JS object - transformResponse: [res => res], - }); - }, - - 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 = Helper.getFileExtension(url); - - 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.lastIndexOf('blob'); - - if (blobIndex > -1) urlArray[blobIndex] = 'tree'; - - return urlArray.join('/'); - }, - - commitFiles(payload) { - return Api.commitMultiple(Store.projectId, payload) - .then(this.commitFlash); - }, - - commitFlash(data) { - if (data.short_id && data.stats) { - window.Flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); - } else { - window.Flash(data.message); - } - }, -}; - -export default RepoService; diff --git a/app/assets/javascripts/repo/stores/actions.js b/app/assets/javascripts/repo/stores/actions.js new file mode 100644 index 00000000000..ca2f2a5ce7a --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions.js @@ -0,0 +1,129 @@ +import Vue from 'vue'; +import flash from '../../flash'; +import service from '../services'; +import * as types from './mutation_types'; + +export const redirectToUrl = url => gl.utils.visitUrl(url); + +export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); + +export const closeDiscardPopup = ({ commit }) => commit(types.TOGGLE_DISCARD_POPUP, false); + +export const discardAllChanges = ({ commit, getters, dispatch }) => { + const changedFiles = getters.changedFiles; + + changedFiles.forEach((file) => { + commit(types.DISCARD_FILE_CHANGES, file); + + if (file.tempFile) { + dispatch('closeFile', { file, force: true }); + } + }); +}; + +export const closeAllFiles = ({ state, dispatch }) => { + state.openFiles.forEach(file => dispatch('closeFile', { file })); +}; + +export const toggleEditMode = ({ state, commit, getters, dispatch }, force = false) => { + const changedFiles = getters.changedFiles; + + if (changedFiles.length && !force) { + commit(types.TOGGLE_DISCARD_POPUP, true); + } else { + commit(types.TOGGLE_EDIT_MODE); + commit(types.TOGGLE_DISCARD_POPUP, false); + dispatch('toggleBlobView'); + + if (!state.editMode) { + dispatch('discardAllChanges'); + } + } +}; + +export const toggleBlobView = ({ commit, state }) => { + if (state.editMode) { + commit(types.SET_EDIT_MODE); + } else { + commit(types.SET_PREVIEW_MODE); + } +}; + +export const checkCommitStatus = ({ state }) => service.getBranchData( + state.project.id, + state.currentBranch, +) + .then((data) => { + const { id } = data.commit; + + if (state.currentRef !== id) { + return true; + } + + return false; + }) + .catch(() => flash('Error checking branch data. Please try again.')); + +export const commitChanges = ({ commit, state, dispatch }, { payload, newMr }) => + service.commit(state.project.id, payload) + .then((data) => { + const { branch } = payload; + if (!data.short_id) { + flash(data.message); + return; + } + + flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); + + if (newMr) { + redirectToUrl(`${state.endpoints.newMergeRequestUrl}${branch}`); + } else { + commit(types.SET_COMMIT_REF, data.id); + dispatch('discardAllChanges'); + dispatch('closeAllFiles'); + dispatch('toggleEditMode'); + + window.scrollTo(0, 0); + } + }) + .catch(() => flash('Error committing changes. Please try again.')); + +export const createTempEntry = ({ state, dispatch }, { name, type, content = '', base64 = false }) => { + if (type === 'tree') { + dispatch('createTempTree', name); + } else if (type === 'blob') { + dispatch('createTempFile', { + tree: state, + name, + base64, + content, + }); + } +}; + +export const popHistoryState = ({ state, dispatch, getters }) => { + const treeList = getters.treeList; + const tree = treeList.find(file => file.url === state.previousUrl); + + if (!tree) return; + + if (tree.type === 'tree') { + dispatch('toggleTreeOpen', { endpoint: tree.url, tree }); + } +}; + +export const scrollToTab = () => { + Vue.nextTick(() => { + const tabs = document.getElementById('tabs'); + + if (tabs) { + const tabEl = tabs.querySelector('.active .repo-tab'); + + tabEl.focus(); + } + }); +}; + +export * from './actions/tree'; +export * from './actions/file'; +export * from './actions/branch'; diff --git a/app/assets/javascripts/repo/stores/actions/branch.js b/app/assets/javascripts/repo/stores/actions/branch.js new file mode 100644 index 00000000000..b81a70dfd1e --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions/branch.js @@ -0,0 +1,20 @@ +import service from '../../services'; +import * as types from '../mutation_types'; +import { pushState } from '../utils'; + +// eslint-disable-next-line import/prefer-default-export +export const createNewBranch = ({ rootState, commit }, branch) => service.createBranch( + rootState.project.id, + { + branch, + ref: rootState.currentBranch, + }, +).then(res => res.json()) +.then((data) => { + const branchName = data.name; + const url = location.href.replace(rootState.currentBranch, branchName); + + pushState(url); + + commit(types.SET_CURRENT_BRANCH, branchName); +}); diff --git a/app/assets/javascripts/repo/stores/actions/file.js b/app/assets/javascripts/repo/stores/actions/file.js new file mode 100644 index 00000000000..afbe0b78a82 --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions/file.js @@ -0,0 +1,108 @@ +import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import flash from '../../../flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import { + findEntry, + pushState, + setPageTitle, + createTemp, + findIndexOfFile, +} from '../utils'; + +export const closeFile = ({ commit, state, dispatch }, { file, force = false }) => { + if ((file.changed || file.tempFile) && !force) return; + + const indexOfClosedFile = findIndexOfFile(state.openFiles, file); + const fileWasActive = file.active; + + commit(types.TOGGLE_FILE_OPEN, file); + commit(types.SET_FILE_ACTIVE, { file, active: false }); + + if (state.openFiles.length > 0 && fileWasActive) { + const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; + const nextFileToOpen = state.openFiles[nextIndexToOpen]; + + dispatch('setFileActive', nextFileToOpen); + } else if (!state.openFiles.length) { + pushState(file.parentTreeUrl); + } +}; + +export const setFileActive = ({ commit, state, getters, dispatch }, file) => { + const currentActiveFile = getters.activeFile; + + if (file.active) return; + + if (currentActiveFile) { + commit(types.SET_FILE_ACTIVE, { file: currentActiveFile, active: false }); + } + + commit(types.SET_FILE_ACTIVE, { file, active: true }); + dispatch('scrollToTab'); + + // reset hash for line highlighting + location.hash = ''; +}; + +export const getFileData = ({ state, commit, dispatch }, file) => { + commit(types.TOGGLE_LOADING, file); + + service.getFileData(file.url) + .then((res) => { + const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then((data) => { + commit(types.SET_FILE_DATA, { data, file }); + commit(types.TOGGLE_FILE_OPEN, file); + dispatch('setFileActive', file); + commit(types.TOGGLE_LOADING, file); + + pushState(file.url); + }) + .catch(() => { + commit(types.TOGGLE_LOADING, file); + flash('Error loading file data. Please try again.'); + }); +}; + +export const getRawFileData = ({ commit, dispatch }, file) => service.getRawFileData(file) + .then((raw) => { + commit(types.SET_FILE_RAW_DATA, { file, raw }); + }) + .catch(() => flash('Error loading file content. Please try again.')); + +export const changeFileContent = ({ commit }, { file, content }) => { + commit(types.UPDATE_FILE_CONTENT, { file, content }); +}; + +export const createTempFile = ({ state, commit, dispatch }, { tree, name, content = '', base64 = '' }) => { + const file = createTemp({ + name: name.replace(`${state.path}/`, ''), + path: tree.path, + type: 'blob', + level: tree.level !== undefined ? tree.level + 1 : 0, + changed: true, + content, + base64, + }); + + if (findEntry(tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`); + + commit(types.CREATE_TMP_FILE, { + parent: tree, + file, + }); + commit(types.TOGGLE_FILE_OPEN, file); + dispatch('setFileActive', file); + + if (!state.editMode && !file.base64) { + dispatch('toggleEditMode', true); + } + + return Promise.resolve(file); +}; diff --git a/app/assets/javascripts/repo/stores/actions/tree.js b/app/assets/javascripts/repo/stores/actions/tree.js new file mode 100644 index 00000000000..129743c66c2 --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions/tree.js @@ -0,0 +1,110 @@ +import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import flash from '../../../flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import { + pushState, + setPageTitle, + findEntry, + createTemp, +} from '../utils'; + +export const getTreeData = ( + { commit, state }, + { endpoint = state.endpoints.rootEndpoint, tree = state } = {}, +) => { + commit(types.TOGGLE_LOADING, tree); + + service.getTreeData(endpoint) + .then((res) => { + const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then((data) => { + if (!state.isInitialRoot) { + commit(types.SET_ROOT, data.path === '/'); + } + + commit(types.SET_DIRECTORY_DATA, { data, tree }); + commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); + commit(types.TOGGLE_LOADING, tree); + + pushState(endpoint); + }) + .catch(() => { + flash('Error loading tree data. Please try again.'); + commit(types.TOGGLE_LOADING, tree); + }); +}; + +export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => { + if (tree.opened) { + // send empty data to clear the tree + const data = { trees: [], blobs: [], submodules: [] }; + + pushState(tree.parentTreeUrl); + + commit(types.SET_PREVIOUS_URL, tree.parentTreeUrl); + commit(types.SET_DIRECTORY_DATA, { data, tree }); + } else { + commit(types.SET_PREVIOUS_URL, endpoint); + dispatch('getTreeData', { endpoint, tree }); + } + + commit(types.TOGGLE_TREE_OPEN, tree); +}; + +export const clickedTreeRow = ({ commit, dispatch }, row) => { + if (row.type === 'tree') { + dispatch('toggleTreeOpen', { + endpoint: row.url, + tree: row, + }); + } else if (row.type === 'submodule') { + commit(types.TOGGLE_LOADING, row); + + gl.utils.visitUrl(row.url); + } else if (row.type === 'blob' && row.opened) { + dispatch('setFileActive', row); + } else { + dispatch('getFileData', row); + } +}; + +export const createTempTree = ({ state, commit, dispatch }, name) => { + let tree = state; + const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/'); + + dirNames.forEach((dirName) => { + const foundEntry = findEntry(tree, 'tree', dirName); + + if (!foundEntry) { + const tmpEntry = createTemp({ + name: dirName, + path: tree.path, + type: 'tree', + level: tree.level !== undefined ? tree.level + 1 : 0, + }); + + commit(types.CREATE_TMP_TREE, { + parent: tree, + tmpEntry, + }); + commit(types.TOGGLE_TREE_OPEN, tmpEntry); + + tree = tmpEntry; + } else { + tree = foundEntry; + } + }); + + if (tree.tempFile) { + dispatch('createTempFile', { + tree, + name: '.gitkeep', + }); + } +}; diff --git a/app/assets/javascripts/repo/stores/getters.js b/app/assets/javascripts/repo/stores/getters.js new file mode 100644 index 00000000000..1ed05ac6e35 --- /dev/null +++ b/app/assets/javascripts/repo/stores/getters.js @@ -0,0 +1,36 @@ +import _ from 'underscore'; + +/* + Takes the multi-dimensional tree and returns a flattened array. + This allows for the table to recursively render the table rows but keeps the data + structure nested to make it easier to add new files/directories. +*/ +export const treeList = (state) => { + const mapTree = arr => (!arr.tree.length ? [] : _.map(arr.tree, a => [a, mapTree(a)])); + + return _.chain(state.tree) + .map(arr => [arr, mapTree(arr)]) + .flatten() + .value(); +}; + +export const changedFiles = state => state.openFiles.filter(file => file.changed); + +export const activeFile = state => state.openFiles.find(file => file.active); + +export const activeFileExtension = (state) => { + const file = activeFile(state); + return file ? `.${file.path.split('.').pop()}` : ''; +}; + +export const isCollapsed = state => !!state.openFiles.length; + +export const canEditFile = (state) => { + const currentActiveFile = activeFile(state); + const openedFiles = state.openFiles; + + return state.canCommit && + state.onTopOfBranch && + openedFiles.length && + (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); +}; diff --git a/app/assets/javascripts/repo/stores/index.js b/app/assets/javascripts/repo/stores/index.js new file mode 100644 index 00000000000..6ac9bfd8189 --- /dev/null +++ b/app/assets/javascripts/repo/stores/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: state(), + actions, + mutations, + getters, +}); diff --git a/app/assets/javascripts/repo/stores/mutation_types.js b/app/assets/javascripts/repo/stores/mutation_types.js new file mode 100644 index 00000000000..4722a7dd0df --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutation_types.js @@ -0,0 +1,28 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; +export const SET_COMMIT_REF = 'SET_COMMIT_REF'; +export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL'; +export const SET_ROOT = 'SET_ROOT'; +export const SET_PREVIOUS_URL = 'SET_PREVIOUS_URL'; + +// Tree mutation types +export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; +export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; +export const CREATE_TMP_TREE = 'CREATE_TMP_TREE'; + +// File mutation types +export const SET_FILE_DATA = 'SET_FILE_DATA'; +export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; +export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; +export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; +export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; +export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; +export const CREATE_TMP_FILE = 'CREATE_TMP_FILE'; + +// Viewer mutation types +export const SET_PREVIEW_MODE = 'SET_PREVIEW_MODE'; +export const SET_EDIT_MODE = 'SET_EDIT_MODE'; +export const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE'; +export const TOGGLE_DISCARD_POPUP = 'TOGGLE_DISCARD_POPUP'; + +export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; diff --git a/app/assets/javascripts/repo/stores/mutations.js b/app/assets/javascripts/repo/stores/mutations.js new file mode 100644 index 00000000000..2f9b038322b --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations.js @@ -0,0 +1,54 @@ +import * as types from './mutation_types'; +import fileMutations from './mutations/file'; +import treeMutations from './mutations/tree'; +import branchMutations from './mutations/branch'; + +export default { + [types.SET_INITIAL_DATA](state, data) { + Object.assign(state, data); + }, + [types.SET_PREVIEW_MODE](state) { + Object.assign(state, { + currentBlobView: 'repo-preview', + }); + }, + [types.SET_EDIT_MODE](state) { + Object.assign(state, { + currentBlobView: 'repo-editor', + }); + }, + [types.TOGGLE_LOADING](state, entry) { + Object.assign(entry, { + loading: !entry.loading, + }); + }, + [types.TOGGLE_EDIT_MODE](state) { + Object.assign(state, { + editMode: !state.editMode, + }); + }, + [types.TOGGLE_DISCARD_POPUP](state, discardPopupOpen) { + Object.assign(state, { + discardPopupOpen, + }); + }, + [types.SET_COMMIT_REF](state, ref) { + Object.assign(state, { + currentRef: ref, + }); + }, + [types.SET_ROOT](state, isRoot) { + Object.assign(state, { + isRoot, + isInitialRoot: isRoot, + }); + }, + [types.SET_PREVIOUS_URL](state, previousUrl) { + Object.assign(state, { + previousUrl, + }); + }, + ...fileMutations, + ...treeMutations, + ...branchMutations, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/branch.js b/app/assets/javascripts/repo/stores/mutations/branch.js new file mode 100644 index 00000000000..d8229e8a620 --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations/branch.js @@ -0,0 +1,9 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_BRANCH](state, currentBranch) { + Object.assign(state, { + currentBranch, + }); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/file.js b/app/assets/javascripts/repo/stores/mutations/file.js new file mode 100644 index 00000000000..f9ba80b9dc2 --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations/file.js @@ -0,0 +1,54 @@ +import * as types from '../mutation_types'; +import { findIndexOfFile } from '../utils'; + +export default { + [types.SET_FILE_ACTIVE](state, { file, active }) { + Object.assign(file, { + active, + }); + }, + [types.TOGGLE_FILE_OPEN](state, file) { + Object.assign(file, { + opened: !file.opened, + }); + + if (file.opened) { + state.openFiles.push(file); + } else { + state.openFiles.splice(findIndexOfFile(state.openFiles, file), 1); + } + }, + [types.SET_FILE_DATA](state, { data, file }) { + Object.assign(file, { + blamePath: data.blame_path, + commitsPath: data.commits_path, + permalink: data.permalink, + rawPath: data.raw_path, + binary: data.binary, + html: data.html, + renderError: data.render_error, + }); + }, + [types.SET_FILE_RAW_DATA](state, { file, raw }) { + Object.assign(file, { + raw, + }); + }, + [types.UPDATE_FILE_CONTENT](state, { file, content }) { + const changed = content !== file.raw; + + Object.assign(file, { + content, + changed, + }); + }, + [types.DISCARD_FILE_CHANGES](state, file) { + Object.assign(file, { + content: '', + changed: false, + }); + }, + [types.CREATE_TMP_FILE](state, { file, parent }) { + parent.tree.push(file); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/tree.js b/app/assets/javascripts/repo/stores/mutations/tree.js new file mode 100644 index 00000000000..52be2673107 --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations/tree.js @@ -0,0 +1,45 @@ +import * as types from '../mutation_types'; +import * as utils from '../utils'; + +export default { + [types.TOGGLE_TREE_OPEN](state, tree) { + Object.assign(tree, { + opened: !tree.opened, + }); + }, + [types.SET_DIRECTORY_DATA](state, { data, tree }) { + const level = tree.level !== undefined ? tree.level + 1 : 0; + const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; + + Object.assign(tree, { + tree: [ + ...data.trees.map(t => utils.decorateData({ + ...t, + type: 'tree', + parentTreeUrl, + level, + }, state.project.url)), + ...data.submodules.map(m => utils.decorateData({ + ...m, + type: 'submodule', + parentTreeUrl, + level, + }, state.project.url)), + ...data.blobs.map(b => utils.decorateData({ + ...b, + type: 'blob', + parentTreeUrl, + level, + }, state.project.url)), + ], + }); + }, + [types.SET_PARENT_TREE_URL](state, url) { + Object.assign(state, { + parentTreeUrl: url, + }); + }, + [types.CREATE_TMP_TREE](state, { parent, tmpEntry }) { + parent.tree.push(tmpEntry); + }, +}; diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js deleted file mode 100644 index 1c0df528aea..00000000000 --- a/app/assets/javascripts/repo/stores/repo_store.js +++ /dev/null @@ -1,199 +0,0 @@ -/* global Flash */ -import Helper from '../helpers/repo_helper'; -import Service from '../services/repo_service'; - -const RepoStore = { - monaco: {}, - monacoLoading: false, - service: '', - canCommit: false, - onTopOfBranch: false, - editMode: false, - isTree: false, - isRoot: false, - prevURL: '', - projectId: '', - projectName: '', - projectUrl: '', - blobRaw: '', - currentBlobView: 'repo-preview', - openedFiles: [], - submitCommitsLoading: false, - dialog: { - open: false, - title: '', - status: false, - }, - activeFile: Helper.getDefaultActiveFile(), - activeFileIndex: 0, - activeLine: 0, - activeFileLabel: 'Raw', - files: [], - isCommitable: false, - binary: false, - currentBranch: '', - targetBranch: 'new-branch', - commitMessage: '', - binaryTypes: { - png: false, - md: false, - svg: false, - unknown: false, - }, - loading: { - tree: false, - blob: false, - }, - - resetBinaryTypes() { - Object.keys(RepoStore.binaryTypes).forEach((key) => { - RepoStore.binaryTypes[key] = false; - }); - }, - - // mutations - checkIsCommitable() { - RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit; - }, - - addFilesToDirectory(inDirectory, currentList, newList) { - RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList); - }, - - toggleRawPreview() { - RepoStore.activeFile.raw = !RepoStore.activeFile.raw; - RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source'; - }, - - 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; - } else if (file.newContent || file.plain) { - RepoStore.blobRaw = file.newContent || file.plain; - } else { - Service.getRaw(file.raw_path) - .then((rawResponse) => { - RepoStore.blobRaw = rawResponse.data; - Helper.findOpenedFileFromActive().plain = rawResponse.data; - }).catch(Helper.loadingError); - } - - if (!file.loading) Helper.updateHistoryEntry(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 canStopSearching = false; - RepoStore.files = RepoStore.files.filter((file) => { - const isItTheTreeWeWant = file.url === treeToClose.url; - // if it's the next tree - if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) { - canStopSearching = true; - return true; - } - if (canStopSearching) return true; - - if (isItTheTreeWeWant) foundTree = true; - - if (foundTree) return file.level <= treeToClose.level; - return true; - }); - - treeToClose.opened = false; - treeToClose.icon = 'fa-folder'; - return treeToClose; - }, - - removeFromOpenedFiles(file) { - if (file.type === 'tree') return; - let foundIndex; - RepoStore.openedFiles = RepoStore.openedFiles.filter((openedFile, i) => { - if (openedFile.path === file.path) foundIndex = i; - return openedFile.path !== file.path; - }); - - // 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 && foundIndex > 0) { - RepoStore.setActiveFiles(RepoStore.openedFiles[foundIndex - 1]); - } - }, - - addToOpenedFiles(file) { - const openFile = file; - - const openedFilesAlreadyExists = RepoStore.openedFiles - .some(openedFile => openedFile.path === openFile.path); - - 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; - }, - - toggleBlobView() { - RepoStore.currentBlobView = RepoStore.isPreviewView() ? 'repo-editor' : 'repo-preview'; - }, - - setViewToPreview() { - RepoStore.currentBlobView = 'repo-preview'; - }, - - // getters - - isActiveFile(file) { - return file && file.url === RepoStore.activeFile.url; - }, - - isPreviewView() { - return RepoStore.currentBlobView === 'repo-preview'; - }, -}; - -export default RepoStore; diff --git a/app/assets/javascripts/repo/stores/state.js b/app/assets/javascripts/repo/stores/state.js new file mode 100644 index 00000000000..aab74754f02 --- /dev/null +++ b/app/assets/javascripts/repo/stores/state.js @@ -0,0 +1,23 @@ +export default () => ({ + canCommit: false, + currentBranch: '', + currentBlobView: 'repo-preview', + currentRef: '', + discardPopupOpen: false, + editMode: false, + endpoints: {}, + isRoot: false, + isInitialRoot: false, + loading: false, + onTopOfBranch: false, + openFiles: [], + path: '', + project: { + id: 0, + name: '', + url: '', + }, + parentTreeUrl: '', + previousUrl: '', + tree: [], +}); diff --git a/app/assets/javascripts/repo/stores/utils.js b/app/assets/javascripts/repo/stores/utils.js new file mode 100644 index 00000000000..797c2b1e5b9 --- /dev/null +++ b/app/assets/javascripts/repo/stores/utils.js @@ -0,0 +1,108 @@ +export const dataStructure = () => ({ + id: '', + type: '', + name: '', + url: '', + path: '', + level: 0, + tempFile: false, + icon: '', + tree: [], + loading: false, + opened: false, + active: false, + changed: false, + lastCommit: {}, + tree_url: '', + blamePath: '', + commitsPath: '', + permalink: '', + rawPath: '', + binary: false, + html: '', + raw: '', + content: '', + parentTreeUrl: '', + renderError: false, + base64: false, +}); + +export const decorateData = (entity, projectUrl = '') => { + const { + id, + type, + url, + name, + icon, + last_commit, + tree_url, + path, + renderError, + content = '', + tempFile = false, + active = false, + opened = false, + changed = false, + parentTreeUrl = '', + level = 0, + base64 = false, + } = entity; + + return { + ...dataStructure(), + id, + type, + name, + url, + tree_url, + path, + level, + tempFile, + icon: `fa-${icon}`, + opened, + active, + parentTreeUrl, + changed, + renderError, + content, + base64, + // eslint-disable-next-line camelcase + lastCommit: last_commit ? { + url: `${projectUrl}/commit/${last_commit.id}`, + message: last_commit.message, + updatedAt: last_commit.committed_date, + } : {}, + }; +}; + +export const findEntry = (state, type, name) => state.tree.find( + f => f.type === type && f.name === name, +); +export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); + +export const setPageTitle = (title) => { + document.title = title; +}; + +export const pushState = (url) => { + history.pushState({ url }, '', url); +}; + +export const createTemp = ({ name, path, type, level, changed, content, base64 }) => { + const treePath = path ? `${path}/${name}` : name; + + return decorateData({ + id: new Date().getTime().toString(), + name, + type, + tempFile: true, + path: treePath, + icon: type === 'tree' ? 'folder' : 'file-text-o', + changed, + content, + parentTreeUrl: '', + level, + base64, + renderError: base64, + }); +}; |