diff options
author | Phil Hughes <me@iamphill.com> | 2018-03-20 14:12:48 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-03-20 14:12:48 +0000 |
commit | f527e6e1855f30cf5ca5cb834b2d20438299a70e (patch) | |
tree | 9d5dd3e33f86cf43c0b445822341896e20027f94 | |
parent | 68b914c9371e6e3fffc6afde23ca77a77ca688f2 (diff) | |
download | gitlab-ce-f527e6e1855f30cf5ca5cb834b2d20438299a70e.tar.gz |
Move IDE to CE
This also makes the IDE generally available
118 files changed, 8988 insertions, 145 deletions
diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue new file mode 100644 index 00000000000..0c54c992e51 --- /dev/null +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -0,0 +1,31 @@ +<script> + import icon from '~/vue_shared/components/icon.vue'; + + export default { + components: { + icon, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + changedIcon() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; + }, + changedIconClass() { + return `multi-${this.changedIcon}`; + }, + }, + }; +</script> + +<template> + <icon + :name="changedIcon" + :size="12" + :css-classes="`ide-file-changed-icon ${changedIconClass}`" + /> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue new file mode 100644 index 00000000000..2cbd982af19 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -0,0 +1,65 @@ +<script> + import { mapState } from 'vuex'; + import { sprintf, __ } from '~/locale'; + import * as consts from '../../stores/modules/commit/constants'; + import RadioGroup from './radio_group.vue'; + + export default { + components: { + RadioGroup, + }, + computed: { + ...mapState([ + 'currentBranchId', + ]), + newMergeRequestHelpText() { + return sprintf( + __('Creates a new branch from %{branchName} and re-directs to create a new merge request'), + { branchName: this.currentBranchId }, + ); + }, + commitToCurrentBranchText() { + return sprintf( + __('Commit to %{branchName} branch'), + { branchName: `<strong>${this.currentBranchId}</strong>` }, + false, + ); + }, + commitToNewBranchText() { + return sprintf( + __('Creates a new branch from %{branchName}'), + { branchName: this.currentBranchId }, + ); + }, + }, + commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, + commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, + commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR, + }; +</script> + +<template> + <div class="append-bottom-15 ide-commit-radios"> + <radio-group + :value="$options.commitToCurrentBranch" + :checked="true" + > + <span + v-html="commitToCurrentBranchText" + > + </span> + </radio-group> + <radio-group + :value="$options.commitToNewBranch" + :label="__('Create a new branch')" + :show-input="true" + :help-text="commitToNewBranchText" + /> + <radio-group + :value="$options.commitToNewBranchMR" + :label="__('Create a new branch and merge request')" + :show-input="true" + :help-text="newMergeRequestHelpText" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue new file mode 100644 index 00000000000..453208f3f19 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -0,0 +1,66 @@ +<script> + import { mapState } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import listItem from './list_item.vue'; + import listCollapsed from './list_collapsed.vue'; + + export default { + components: { + icon, + listItem, + listCollapsed, + }, + props: { + title: { + type: String, + required: true, + }, + fileList: { + type: Array, + required: true, + }, + }, + computed: { + ...mapState([ + 'currentProjectId', + 'currentBranchId', + 'rightPanelCollapsed', + ]), + isCommitInfoShown() { + return this.rightPanelCollapsed || this.fileList.length; + }, + }, + methods: { + toggleCollapsed() { + this.$emit('toggleCollapsed'); + }, + }, + }; +</script> + +<template> + <div + :class="{ + 'multi-file-commit-list': isCommitInfoShown + }" + > + <list-collapsed + v-if="rightPanelCollapsed" + /> + <template v-else> + <ul + v-if="fileList.length" + class="list-unstyled append-bottom-0" + > + <li + v-for="file in fileList" + :key="file.key" + > + <list-item + :file="file" + /> + </li> + </ul> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue new file mode 100644 index 00000000000..15918ac9631 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -0,0 +1,35 @@ +<script> + import { mapGetters } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + + export default { + components: { + icon, + }, + computed: { + ...mapGetters([ + 'addedFiles', + 'modifiedFiles', + ]), + }, + }; +</script> + +<template> + <div + class="multi-file-commit-list-collapsed text-center" + > + <icon + name="file-addition" + :size="18" + css-classes="multi-file-addition append-bottom-10" + /> + {{ addedFiles.length }} + <icon + name="file-modified" + :size="18" + css-classes="multi-file-modified prepend-top-10 append-bottom-10" + /> + {{ modifiedFiles.length }} + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue new file mode 100644 index 00000000000..18934af004a --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -0,0 +1,60 @@ +<script> + import { mapActions } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import router from '../../ide_router'; + + export default { + components: { + icon, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + iconName() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; + }, + iconClass() { + return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; + }, + }, + methods: { + ...mapActions([ + 'discardFileChanges', + 'updateViewer', + ]), + openFileInEditor(file) { + this.updateViewer('diff'); + + router.push(`/project${file.url}`); + }, + }, + }; +</script> + +<template> + <div class="multi-file-commit-list-item"> + <button + type="button" + class="multi-file-commit-list-path" + @click="openFileInEditor(file)"> + <span class="multi-file-commit-list-file-path"> + <icon + :name="iconName" + :size="16" + :css-classes="iconClass" + />{{ file.path }} + </span> + </button> + <button + type="button" + class="btn btn-blank multi-file-discard-btn" + @click="discardFileChanges(file.path)" + > + Discard + </button> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue new file mode 100644 index 00000000000..4310d762c78 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -0,0 +1,94 @@ +<script> + import { mapActions, mapState, mapGetters } from 'vuex'; + import tooltip from '~/vue_shared/directives/tooltip'; + + export default { + directives: { + tooltip, + }, + props: { + value: { + type: String, + required: true, + }, + label: { + type: String, + required: false, + default: null, + }, + checked: { + type: Boolean, + required: false, + default: false, + }, + showInput: { + type: Boolean, + required: false, + default: false, + }, + helpText: { + type: String, + required: false, + default: null, + }, + }, + computed: { + ...mapState('commit', [ + 'commitAction', + ]), + ...mapGetters('commit', [ + 'newBranchName', + ]), + }, + methods: { + ...mapActions('commit', [ + 'updateCommitAction', + 'updateBranchName', + ]), + }, + }; +</script> + +<template> + <fieldset> + <label> + <input + type="radio" + name="commit-action" + :value="value" + @change="updateCommitAction($event.target.value)" + :checked="checked" + v-once + /> + <span class="prepend-left-10"> + <template v-if="label"> + {{ label }} + </template> + <slot v-else></slot> + <span + v-if="helpText" + v-tooltip + class="help-block inline" + :title="helpText" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + > + </i> + </span> + </span> + </label> + <div + v-if="commitAction === value && showInput" + class="ide-commit-new-branch" + > + <input + type="text" + class="form-control" + :placeholder="newBranchName" + @input="updateBranchName($event.target.value)" + /> + </div> + </fieldset> +</template> diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue new file mode 100644 index 00000000000..170347881e0 --- /dev/null +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -0,0 +1,91 @@ +<script> + import Icon from '~/vue_shared/components/icon.vue'; + + export default { + components: { + Icon, + }, + props: { + hasChanges: { + type: Boolean, + required: false, + default: false, + }, + viewer: { + type: String, + required: true, + }, + showShadow: { + type: Boolean, + required: true, + }, + }, + methods: { + changeMode(mode) { + this.$emit('click', mode); + }, + }, + }; +</script> + +<template> + <div + class="dropdown" + :class="{ + shadow: showShadow, + }" + > + <button + type="button" + class="btn btn-primary btn-sm" + :class="{ + 'btn-inverted': hasChanges, + }" + data-toggle="dropdown" + > + <template v-if="viewer === 'editor'"> + {{ __('Editing') }} + </template> + <template v-else> + {{ __('Reviewing') }} + </template> + <icon + name="angle-down" + :size="12" + css-classes="caret-down" + /> + </button> + <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> + <ul> + <li> + <a + href="#" + @click.prevent="changeMode('editor')" + :class="{ + 'is-active': viewer === 'editor', + }" + > + <strong class="dropdown-menu-inner-title">{{ __('Editing') }}</strong> + <span class="dropdown-menu-inner-content"> + {{ __('View and edit lines') }} + </span> + </a> + </li> + <li> + <a + href="#" + @click.prevent="changeMode('diff')" + :class="{ + 'is-active': viewer === 'diff', + }" + > + <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong> + <span class="dropdown-menu-inner-content"> + {{ __('Compare changes with the last commit') }} + </span> + </a> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue new file mode 100644 index 00000000000..015e750525a --- /dev/null +++ b/app/assets/javascripts/ide/components/ide.vue @@ -0,0 +1,111 @@ +<script> + import { mapState, mapGetters } from 'vuex'; + import ideSidebar from './ide_side_bar.vue'; + import ideContextbar from './ide_context_bar.vue'; + import repoTabs from './repo_tabs.vue'; + import repoFileButtons from './repo_file_buttons.vue'; + import ideStatusBar from './ide_status_bar.vue'; + import repoEditor from './repo_editor.vue'; + + export default { + components: { + ideSidebar, + ideContextbar, + repoTabs, + repoFileButtons, + ideStatusBar, + repoEditor, + }, + props: { + emptyStateSvgPath: { + type: String, + required: true, + }, + noChangesStateSvgPath: { + type: String, + required: true, + }, + committedStateSvgPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['changedFiles', 'openFiles', 'viewer']), + ...mapGetters(['activeFile', 'hasChanges']), + }, + mounted() { + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = e => { + if (!this.changedFiles.length) return undefined; + + Object.assign(e, { + returnValue, + }); + return returnValue; + }; + }, + }; +</script> + +<template> + <div + class="ide-view" + > + <ide-sidebar /> + <div + class="multi-file-edit-pane" + > + <template + v-if="activeFile" + > + <repo-tabs + :files="openFiles" + :viewer="viewer" + :has-changes="hasChanges" + /> + <repo-editor + class="multi-file-edit-pane-content" + :file="activeFile" + /> + <repo-file-buttons + :file="activeFile" + /> + <ide-status-bar + :file="activeFile" + /> + </template> + <template + v-else + > + <div + v-once + class="ide-empty-state" + > + <div class="row js-empty-state"> + <div class="col-xs-12"> + <div class="svg-content svg-250"> + <img :src="emptyStateSvgPath" /> + </div> + </div> + <div class="col-xs-12"> + <div class="text-content text-center"> + <h4> + Welcome to the GitLab IDE + </h4> + <p> + You can select a file in the left sidebar to begin + editing and use the right sidebar to commit your changes. + </p> + </div> + </div> + </div> + </div> + </template> + </div> + <ide-contextbar + :no-changes-state-svg-path="noChangesStateSvgPath" + :committed-state-svg-path="committedStateSvgPath" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue new file mode 100644 index 00000000000..79a83b47994 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_context_bar.vue @@ -0,0 +1,84 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import icon from '~/vue_shared/components/icon.vue'; +import panelResizer from '~/vue_shared/components/panel_resizer.vue'; +import repoCommitSection from './repo_commit_section.vue'; +import ResizablePanel from './resizable_panel.vue'; + +export default { + components: { + repoCommitSection, + icon, + panelResizer, + ResizablePanel, + }, + props: { + noChangesStateSvgPath: { + type: String, + required: true, + }, + committedStateSvgPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['changedFiles', 'rightPanelCollapsed']), + ...mapGetters(['currentIcon']), + }, + methods: { + ...mapActions(['setPanelCollapsedStatus']), + }, +}; +</script> + +<template> + <resizable-panel + :collapsible="true" + :initial-width="340" + side="right" + > + <div + class="multi-file-commit-panel-section" + > + <header + class="multi-file-commit-panel-header" + :class="{ + 'is-collapsed': rightPanelCollapsed, + }" + > + <div + class="multi-file-commit-panel-header-title" + v-if="!rightPanelCollapsed" + > + <div + v-if="changedFiles.length" + > + <icon + name="list-bulleted" + :size="18" + /> + Staged + </div> + </div> + <button + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + @click.stop="setPanelCollapsedStatus({ + side: 'right', + collapsed: !rightPanelCollapsed, + })" + > + <icon + :name="currentIcon" + :size="18" + /> + </button> + </header> + <repo-commit-section + :no-changes-state-svg-path="noChangesStateSvgPath" + :committed-state-svg-path="committedStateSvgPath" + /> + </div> + </resizable-panel> +</template> diff --git a/app/assets/javascripts/ide/components/ide_external_links.vue b/app/assets/javascripts/ide/components/ide_external_links.vue new file mode 100644 index 00000000000..c6f6e0d2348 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_external_links.vue @@ -0,0 +1,43 @@ +<script> +import icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + icon, + }, + props: { + projectUrl: { + type: String, + required: true, + }, + }, + computed: { + goBackUrl() { + return document.referrer || this.projectUrl; + }, + }, +}; +</script> + +<template> + <nav + class="ide-external-links" + v-once + > + <p> + <a + :href="goBackUrl" + class="ide-sidebar-link" + > + <icon + :size="16" + class="append-right-8" + name="go-back" + /> + <span class="ide-external-links-text"> + {{ s__('Go back') }} + </span> + </a> + </p> + </nav> +</template> diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue new file mode 100644 index 00000000000..eb2749e6151 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue @@ -0,0 +1,47 @@ +<script> + import icon from '~/vue_shared/components/icon.vue'; + import repoTree from './ide_repo_tree.vue'; + import newDropdown from './new_dropdown/index.vue'; + + export default { + components: { + repoTree, + icon, + newDropdown, + }, + props: { + projectId: { + type: String, + required: true, + }, + branch: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <div class="branch-container"> + <div class="branch-header"> + <div class="branch-header-title str-truncated ref-name"> + <icon + name="branch" + :size="12" + /> + {{ branch.name }} + </div> + <div class="branch-header-btns"> + <new-dropdown + :project-id="projectId" + :branch="branch.name" + path="" + /> + </div> + </div> + <repo-tree + :tree="branch.tree" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue new file mode 100644 index 00000000000..220db1abfb0 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_tree.vue @@ -0,0 +1,54 @@ +<script> +import projectAvatarImage from '~/vue_shared/components/project_avatar/image.vue'; +import branchesTree from './ide_project_branches_tree.vue'; +import externalLinks from './ide_external_links.vue'; + +export default { + components: { + branchesTree, + externalLinks, + projectAvatarImage, + }, + props: { + project: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="projects-sidebar"> + <div class="context-header"> + <a + :title="project.name" + :href="project.web_url" + > + <div class="avatar-container s40 project-avatar"> + <project-avatar-image + class="avatar-container project-avatar" + :link-href="project.path" + :img-src="project.avatar_url" + :img-alt="project.name" + :img-size="40" + /> + </div> + <div class="sidebar-context-title"> + {{ project.name }} + </div> + </a> + </div> + <external-links + :project-url="project.web_url" + /> + <div class="multi-file-commit-panel-inner-scroll"> + <branches-tree + v-for="branch in project.branches" + :key="branch.name" + :project-id="project.path_with_namespace" + :branch="branch" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue new file mode 100644 index 00000000000..e6af88e04bc --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue @@ -0,0 +1,41 @@ +<script> +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import RepoFile from './repo_file.vue'; + +export default { + components: { + RepoFile, + SkeletonLoadingContainer, + }, + props: { + tree: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div + class="ide-file-list" + > + <template v-if="tree.loading"> + <div + class="multi-file-loading-container" + v-for="n in 3" + :key="n" + > + <skeleton-loading-container /> + </div> + </template> + <template v-else> + <repo-file + v-for="file in tree.tree" + :key="file.key" + :file="file" + :level="0" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue new file mode 100644 index 00000000000..8cf1ccb4fce --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -0,0 +1,51 @@ +<script> + import { mapState, mapGetters } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import panelResizer from '~/vue_shared/components/panel_resizer.vue'; + import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + import projectTree from './ide_project_tree.vue'; + import ResizablePanel from './resizable_panel.vue'; + + export default { + components: { + projectTree, + icon, + panelResizer, + skeletonLoadingContainer, + ResizablePanel, + }, + computed: { + ...mapState([ + 'loading', + ]), + ...mapGetters([ + 'projectsWithTrees', + ]), + }, + }; +</script> + +<template> + <resizable-panel + :collapsible="false" + :initial-width="290" + side="left" + > + <div class="multi-file-commit-panel-inner"> + <template v-if="loading"> + <div + class="multi-file-loading-container" + v-for="n in 3" + :key="n" + > + <skeleton-loading-container /> + </div> + </template> + <project-tree + v-for="project in projectsWithTrees" + :key="project.id" + :project="project" + /> + </div> + </resizable-panel> +</template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue new file mode 100644 index 00000000000..9c386896448 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -0,0 +1,60 @@ +<script> + import icon from '~/vue_shared/components/icon.vue'; + import tooltip from '~/vue_shared/directives/tooltip'; + import timeAgoMixin from '~/vue_shared/mixins/timeago'; + + export default { + components: { + icon, + }, + directives: { + tooltip, + }, + mixins: [ + timeAgoMixin, + ], + props: { + file: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <div class="ide-status-bar"> + <div class="ref-name"> + <icon + name="branch" + :size="12" + /> + {{ file.branchId }} + </div> + <div> + <div v-if="file.lastCommit && file.lastCommit.id"> + Last commit: + <a + v-tooltip + :title="file.lastCommit.message" + :href="file.lastCommit.url" + > + {{ timeFormated(file.lastCommit.updatedAt) }} by + {{ file.lastCommit.author }} + </a> + </div> + </div> + <div class="text-right"> + {{ file.name }} + </div> + <div class="text-right"> + {{ file.eol }} + </div> + <div class="text-right"> + {{ file.editorRow }}:{{ file.editorColumn }} + </div> + <div class="text-right"> + {{ file.fileLanguage }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue new file mode 100644 index 00000000000..769e9b79cad --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -0,0 +1,111 @@ +<script> + import { mapActions } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import newModal from './modal.vue'; + import upload from './upload.vue'; + + export default { + components: { + icon, + newModal, + upload, + }, + props: { + branch: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + data() { + return { + openModal: false, + modalType: '', + dropdownOpen: false, + }; + }, + methods: { + ...mapActions([ + 'createTempEntry', + ]), + createNewItem(type) { + this.modalType = type; + this.openModal = true; + this.dropdownOpen = false; + }, + hideModal() { + this.openModal = false; + }, + openDropdown() { + this.dropdownOpen = !this.dropdownOpen; + }, + }, + }; +</script> + +<template> + <div class="ide-new-btn"> + <div + class="dropdown" + :class="{ + open: dropdownOpen, + }" + > + <button + type="button" + class="btn btn-sm btn-default dropdown-toggle add-to-tree" + aria-label="Create new file or directory" + @click.stop="openDropdown()" + > + <icon + name="plus" + :size="12" + css-classes="pull-left" + /> + <icon + name="arrow-down" + :size="12" + css-classes="pull-left" + /> + </button> + <ul class="dropdown-menu dropdown-menu-right"> + <li> + <a + href="#" + role="button" + @click.stop.prevent="createNewItem('blob')" + > + {{ __('New file') }} + </a> + </li> + <li> + <upload + :branch-id="branch" + :path="path" + @create="createTempEntry" + /> + </li> + <li> + <a + href="#" + role="button" + @click.stop.prevent="createNewItem('tree')" + > + {{ __('New directory') }} + </a> + </li> + </ul> + </div> + <new-modal + v-if="openModal" + :type="modalType" + :branch-id="branch" + :path="path" + @hide="hideModal" + @create="createTempEntry" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue new file mode 100644 index 00000000000..5723891d130 --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -0,0 +1,99 @@ +<script> + import { __ } from '~/locale'; + import modal from '~/vue_shared/components/modal.vue'; + + export default { + components: { + modal, + }, + props: { + branchId: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + data() { + return { + entryName: this.path !== '' ? `${this.path}/` : '', + }; + }, + 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(); + }, + methods: { + createEntryInStore() { + this.$emit('create', { + branchId: this.branchId, + name: this.entryName, + type: this.type, + }); + + this.hideModal(); + }, + hideModal() { + this.$emit('hide'); + }, + }, + }; +</script> + +<template> + <modal + :title="modalTitle" + :primary-button-label="buttonLabel" + kind="success" + @cancel="hideModal" + @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> + </modal> +</template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue new file mode 100644 index 00000000000..c165af5ce52 --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -0,0 +1,75 @@ +<script> + export default { + props: { + branchId: { + type: String, + required: true, + }, + path: { + type: String, + required: false, + default: '', + }, + }, + mounted() { + this.$refs.fileUpload.addEventListener('change', this.openFile); + }, + beforeDestroy() { + this.$refs.fileUpload.removeEventListener('change', this.openFile); + }, + methods: { + createFile(target, file, isText) { + const { name } = file; + let { result } = target; + + if (!isText) { + result = result.split('base64,')[1]; + } + + this.$emit('create', { + name: `${(this.path ? `${this.path}/` : '')}${name}`, + branchId: this.branchId, + 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)); + }, + startFileUpload() { + this.$refs.fileUpload.click(); + }, + }, + }; +</script> + +<template> + <div> + <a + href="#" + role="button" + @click.stop.prevent="startFileUpload" + > + {{ __('Upload file') }} + </a> + <input + id="file-upload" + type="file" + class="hidden" + ref="fileUpload" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue new file mode 100644 index 00000000000..d772cab2d0e --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -0,0 +1,174 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import tooltip from '~/vue_shared/directives/tooltip'; +import icon from '~/vue_shared/components/icon.vue'; +import modal from '~/vue_shared/components/modal.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import commitFilesList from './commit_sidebar/list.vue'; +import * as consts from '../stores/modules/commit/constants'; +import Actions from './commit_sidebar/actions.vue'; + +export default { + components: { + modal, + icon, + commitFilesList, + Actions, + LoadingButton, + }, + directives: { + tooltip, + }, + props: { + noChangesStateSvgPath: { + type: String, + required: true, + }, + committedStateSvgPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState([ + 'currentProjectId', + 'currentBranchId', + 'rightPanelCollapsed', + 'lastCommitMsg', + 'changedFiles', + ]), + ...mapState('commit', [ + 'commitMessage', + 'submitCommitLoading', + ]), + ...mapGetters('commit', [ + 'commitButtonDisabled', + 'discardDraftButtonDisabled', + 'branchName', + ]), + statusSvg() { + return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath; + }, + }, + methods: { + ...mapActions([ + 'setPanelCollapsedStatus', + ]), + ...mapActions('commit', [ + 'updateCommitMessage', + 'discardDraft', + 'commitChanges', + 'updateCommitAction', + ]), + toggleCollapsed() { + this.setPanelCollapsedStatus({ + side: 'right', + collapsed: !this.rightPanelCollapsed, + }); + }, + forceCreateNewBranch() { + return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH) + .then(() => this.commitChanges()); + }, + }, +}; +</script> + +<template> + <div + class="multi-file-commit-panel-section" + :class="{ + 'multi-file-commit-empty-state-container': !changedFiles.length + }" + > + <modal + id="ide-create-branch-modal" + :primary-button-label="__('Create new branch')" + kind="success" + :title="__('Branch has changed')" + @submit="forceCreateNewBranch" + > + <template slot="body"> + {{ __(`This branch has changed since you started editing. + Would you like to create a new branch?`) }} + </template> + </modal> + <commit-files-list + title="Staged" + :file-list="changedFiles" + :collapsed="rightPanelCollapsed" + @toggleCollapsed="toggleCollapsed" + /> + <template + v-if="changedFiles.length" + > + <form + class="form-horizontal multi-file-commit-form" + @submit.prevent.stop="commitChanges" + v-if="!rightPanelCollapsed" + > + <div class="multi-file-commit-fieldset"> + <textarea + class="form-control multi-file-commit-message" + name="commit-message" + :value="commitMessage" + :placeholder="__('Write a commit message...')" + @input="updateCommitMessage($event.target.value)" + > + </textarea> + </div> + <div class="clearfix prepend-top-15"> + <actions /> + <loading-button + :loading="submitCommitLoading" + :disabled="commitButtonDisabled" + container-class="btn btn-success btn-sm pull-left" + :label="__('Commit')" + @click="commitChanges" + /> + <button + v-if="!discardDraftButtonDisabled" + type="button" + class="btn btn-default btn-sm pull-right" + @click="discardDraft" + > + {{ __('Discard draft') }} + </button> + </div> + </form> + </template> + <div + v-else-if="!rightPanelCollapsed" + class="row js-empty-state" + > + <div class="col-xs-10 col-xs-offset-1"> + <div class="svg-content svg-80"> + <img :src="statusSvg" /> + </div> + </div> + <div class="col-xs-10 col-xs-offset-1"> + <div + class="text-content text-center" + v-if="!lastCommitMsg" + > + <h4> + {{ __('No changes') }} + </h4> + <p> + {{ __('Edit files in the editor and commit changes here') }} + </p> + </div> + <div + class="text-content text-center" + v-else + > + <h4> + {{ __('All changes are committed') }} + </h4> + <p v-html="lastCommitMsg"> + </p> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue new file mode 100644 index 00000000000..e73d1ce839f --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -0,0 +1,161 @@ +<script> +/* global monaco */ +import { mapState, mapActions } from 'vuex'; +import flash from '~/flash'; +import monacoLoader from '../monaco_loader'; +import Editor from '../lib/editor'; + +export default { + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState([ + 'leftPanelCollapsed', + 'rightPanelCollapsed', + 'viewer', + 'delayViewerUpdated', + ]), + shouldHideEditor() { + return this.file && this.file.binary && !this.file.raw; + }, + }, + watch: { + file(oldVal, newVal) { + if (newVal.path !== this.file.path) { + this.initMonaco(); + } + }, + leftPanelCollapsed() { + this.editor.updateDimensions(); + }, + rightPanelCollapsed() { + this.editor.updateDimensions(); + }, + viewer() { + this.createEditorInstance(); + }, + }, + beforeDestroy() { + this.editor.dispose(); + }, + mounted() { + if (this.editor && monaco) { + this.initMonaco(); + } else { + monacoLoader(['vs/editor/editor.main'], () => { + this.editor = Editor.create(monaco); + + this.initMonaco(); + }); + } + }, + methods: { + ...mapActions([ + 'getRawFileData', + 'changeFileContent', + 'setFileLanguage', + 'setEditorPosition', + 'setFileEOL', + 'updateViewer', + 'updateDelayViewerUpdated', + ]), + initMonaco() { + if (this.shouldHideEditor) return; + + this.editor.clearEditor(); + + this.getRawFileData(this.file) + .then(() => { + const viewerPromise = this.delayViewerUpdated ? this.updateViewer('editor') : Promise.resolve(); + + return viewerPromise; + }) + .then(() => { + this.updateDelayViewerUpdated(false); + this.createEditorInstance(); + }) + .catch((err) => { + flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true); + throw err; + }); + }, + createEditorInstance() { + this.editor.dispose(); + + this.$nextTick(() => { + if (this.viewer === 'editor') { + this.editor.createInstance(this.$refs.editor); + } else { + this.editor.createDiffInstance(this.$refs.editor); + } + + this.setupEditor(); + }); + }, + setupEditor() { + if (!this.file || !this.editor.instance) return; + + this.model = this.editor.createModel(this.file); + + this.editor.attachModel(this.model); + + this.model.onChange((model) => { + const { file } = model; + + if (file.active) { + this.changeFileContent({ + path: file.path, + content: model.getModel().getValue(), + }); + } + }); + + // Handle Cursor Position + this.editor.onPositionChange((instance, e) => { + this.setEditorPosition({ + editorRow: e.position.lineNumber, + editorColumn: e.position.column, + }); + }); + + this.editor.setPosition({ + lineNumber: this.file.editorRow, + column: this.file.editorColumn, + }); + + // Handle File Language + this.setFileLanguage({ + fileLanguage: this.model.language, + }); + + // Get File eol + this.setFileEOL({ + eol: this.model.eol, + }); + }, + }, +}; +</script> + +<template> + <div + id="ide" + class="blob-viewer-container blob-editor-container" + > + <div + v-if="shouldHideEditor" + v-html="file.html" + > + </div> + <div + v-show="!shouldHideEditor" + ref="editor" + class="multi-file-editor-holder" + > + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue new file mode 100644 index 00000000000..03a40096bb0 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -0,0 +1,127 @@ +<script> +import { mapActions } from 'vuex'; +import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import fileIcon from '~/vue_shared/components/file_icon.vue'; +import router from '../ide_router'; +import newDropdown from './new_dropdown/index.vue'; +import fileStatusIcon from './repo_file_status_icon.vue'; +import changedFileIcon from './changed_file_icon.vue'; + +export default { + name: 'RepoFile', + components: { + skeletonLoadingContainer, + newDropdown, + fileStatusIcon, + fileIcon, + changedFileIcon, + }, + props: { + file: { + type: Object, + required: true, + }, + level: { + type: Number, + required: true, + }, + }, + computed: { + isTree() { + return this.file.type === 'tree'; + }, + isBlob() { + return this.file.type === 'blob'; + }, + levelIndentation() { + return { + marginLeft: `${this.level * 16}px`, + }; + }, + fileClass() { + return { + 'file-open': this.isBlob && this.file.opened, + 'file-active': this.isBlob && this.file.active, + folder: this.isTree, + }; + }, + }, + updated() { + if (this.file.type === 'blob' && this.file.active) { + this.$el.scrollIntoView(); + } + }, + methods: { + ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']), + clickFile() { + // Manual Action if a tree is selected/opened + if ( + this.isTree && + this.$router.currentRoute.path === `/project${this.file.url}` + ) { + this.toggleTreeOpen(this.file.path); + } + + const delayPromise = this.file.changed + ? Promise.resolve() + : this.updateDelayViewerUpdated(true); + + return delayPromise.then(() => { + router.push(`/project${this.file.url}`); + }); + }, + }, +}; +</script> + +<template> + <div> + <div + class="file" + :class="fileClass" + > + <div + class="file-name" + @click="clickFile" + role="button" + > + <span + class="ide-file-name str-truncated" + :style="levelIndentation" + > + <file-icon + :file-name="file.name" + :loading="file.loading" + :folder="isTree" + :opened="file.opened" + :size="16" + /> + {{ file.name }} + <file-status-icon + :file="file" + /> + </span> + <changed-file-icon + :file="file" + v-if="file.changed || file.tempFile" + class="prepend-top-5 pull-right" + /> + <new-dropdown + v-if="isTree" + :project-id="file.projectId" + :branch="file.branchId" + :path="file.path" + class="pull-right prepend-left-8" + /> + </div> + </div> + <template v-if="file.opened"> + <repo-file + v-for="childFile in file.tree" + :key="childFile.key" + :file="childFile" + :level="level + 1" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue new file mode 100644 index 00000000000..4ea8cf7504b --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file_buttons.vue @@ -0,0 +1,61 @@ +<script> +export default { + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + showButtons() { + return this.file.rawPath || + this.file.blamePath || + this.file.commitsPath || + this.file.permalink; + }, + rawDownloadButtonLabel() { + return this.file.binary ? 'Download' : 'Raw'; + }, + }, +}; +</script> + +<template> + <div + v-if="showButtons" + class="multi-file-editor-btn-group" + > + <a + :href="file.rawPath" + target="_blank" + class="btn btn-default btn-sm raw" + rel="noopener noreferrer"> + {{ rawDownloadButtonLabel }} + </a> + + <div + class="btn-group" + role="group" + aria-label="File actions" + > + <a + :href="file.blamePath" + class="btn btn-default btn-sm blame" + > + Blame + </a> + <a + :href="file.commitsPath" + class="btn btn-default btn-sm history" + > + History + </a> + <a + :href="file.permalink" + class="btn btn-default btn-sm permalink" + > + Permalink + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue new file mode 100644 index 00000000000..25d311142d5 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -0,0 +1,39 @@ +<script> + import icon from '~/vue_shared/components/icon.vue'; + import tooltip from '~/vue_shared/directives/tooltip'; + import '~/lib/utils/datetime_utility'; + + export default { + components: { + icon, + }, + directives: { + tooltip, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + lockTooltip() { + return `Locked by ${this.file.file_lock.user.name}`; + }, + }, + }; +</script> + +<template> + <span + v-if="file.file_lock" + v-tooltip + :title="lockTooltip" + data-container="body" + > + <icon + name="lock" + css-classes="file-status-icon" + /> + </span> +</template> diff --git a/app/assets/javascripts/ide/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue new file mode 100644 index 00000000000..79af8c0b0c7 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_loading_file.vue @@ -0,0 +1,42 @@ +<script> + import { mapState } from 'vuex'; + import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + + export default { + components: { + skeletonLoadingContainer, + }, + computed: { + ...mapState([ + 'leftPanelCollapsed', + ]), + }, + }; +</script> + +<template> + <tr + class="loading-file" + aria-label="Loading files" + > + <td class="multi-file-table-col-name"> + <skeleton-loading-container + :small="true" + /> + </td> + <template v-if="!leftPanelCollapsed"> + <td class="hidden-sm hidden-xs"> + <skeleton-loading-container + :small="true" + /> + </td> + + <td class="hidden-xs"> + <skeleton-loading-container + class="animation-container-right" + :small="true" + /> + </td> + </template> + </tr> +</template> diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue new file mode 100644 index 00000000000..c337bc813e6 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -0,0 +1,98 @@ +<script> + import { mapActions } from 'vuex'; + + import fileIcon from '~/vue_shared/components/file_icon.vue'; + import icon from '~/vue_shared/components/icon.vue'; + import fileStatusIcon from './repo_file_status_icon.vue'; + import changedFileIcon from './changed_file_icon.vue'; + + export default { + components: { + fileStatusIcon, + fileIcon, + icon, + changedFileIcon, + }, + props: { + tab: { + type: Object, + required: true, + }, + }, + data() { + return { + tabMouseOver: false, + }; + }, + computed: { + closeLabel() { + if (this.tab.changed || this.tab.tempFile) { + return `${this.tab.name} changed`; + } + return `Close ${this.tab.name}`; + }, + showChangedIcon() { + return this.tab.changed ? !this.tabMouseOver : false; + }, + }, + + methods: { + ...mapActions([ + 'closeFile', + ]), + clickFile(tab) { + this.$router.push(`/project${tab.url}`); + }, + mouseOverTab() { + if (this.tab.changed) { + this.tabMouseOver = true; + } + }, + mouseOutTab() { + if (this.tab.changed) { + this.tabMouseOver = false; + } + }, + }, + }; +</script> + +<template> + <li + @click="clickFile(tab)" + @mouseover="mouseOverTab" + @mouseout="mouseOutTab" + > + <button + type="button" + class="multi-file-tab-close" + @click.stop.prevent="closeFile(tab.path)" + :aria-label="closeLabel" + > + <icon + v-if="!showChangedIcon" + name="close" + :size="12" + /> + <changed-file-icon + v-else + :file="tab" + /> + </button> + + <div + class="multi-file-tab" + :class="{active : tab.active }" + :title="tab.url" + > + <file-icon + :file-name="tab.name" + :size="16" + /> + {{ tab.name }} + <file-status-icon + :file="tab" + /> + </div> + </li> +</template> diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue new file mode 100644 index 00000000000..8ea64ddf84a --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -0,0 +1,61 @@ +<script> + import { mapActions } from 'vuex'; + import RepoTab from './repo_tab.vue'; + import EditorMode from './editor_mode_dropdown.vue'; + + export default { + components: { + RepoTab, + EditorMode, + }, + props: { + files: { + type: Array, + required: true, + }, + viewer: { + type: String, + required: true, + }, + hasChanges: { + type: Boolean, + required: true, + }, + }, + data() { + return { + showShadow: false, + }; + }, + updated() { + if (!this.$refs.tabsScroller) return; + + this.showShadow = + this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; + }, + methods: { + ...mapActions(['updateViewer']), + }, + }; +</script> + +<template> + <div class="multi-file-tabs"> + <ul + class="list-unstyled append-bottom-0" + ref="tabsScroller" + > + <repo-tab + v-for="tab in files" + :key="tab.key" + :tab="tab" + /> + </ul> + <editor-mode + :viewer="viewer" + :show-shadow="showShadow" + :has-changes="hasChanges" + @click="updateViewer" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue new file mode 100644 index 00000000000..faa690ecba0 --- /dev/null +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -0,0 +1,88 @@ +<script> + import { mapActions, mapState } from 'vuex'; + import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; + + export default { + components: { + PanelResizer, + }, + props: { + collapsible: { + type: Boolean, + required: true, + }, + initialWidth: { + type: Number, + required: true, + }, + minSize: { + type: Number, + required: false, + default: 200, + }, + side: { + type: String, + required: true, + }, + }, + data() { + return { + width: this.initialWidth, + }; + }, + computed: { + ...mapState({ + collapsed(state) { + return state[`${this.side}PanelCollapsed`]; + }, + }), + panelStyle() { + if (!this.collapsed) { + return { + width: `${this.width}px`, + }; + } + + return {}; + }, + }, + methods: { + ...mapActions([ + 'setPanelCollapsedStatus', + 'setResizingStatus', + ]), + toggleFullbarCollapsed() { + if (this.collapsed && this.collapsible) { + this.setPanelCollapsedStatus({ + side: this.side, + collapsed: !this.collapsed, + }); + } + }, + }, + maxSize: (window.innerWidth / 2), + }; +</script> + +<template> + <div + class="multi-file-commit-panel" + :class="{ + 'is-collapsed': collapsed && collapsible, + }" + :style="panelStyle" + @click="toggleFullbarCollapsed" + > + <slot></slot> + <panel-resizer + :size.sync="width" + :enabled="!collapsed" + :start-size="initialWidth" + :min-size="minSize" + :max-size="$options.maxSize" + @resize-start="setResizingStatus(true)" + @resize-end="setResizingStatus(false)" + :side="side === 'right' ? 'left' : 'right'" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/eventhub.js b/app/assets/javascripts/ide/eventhub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/ide/eventhub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js new file mode 100644 index 00000000000..048d5316922 --- /dev/null +++ b/app/assets/javascripts/ide/ide_router.js @@ -0,0 +1,97 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import flash from '~/flash'; +import store from './stores'; + +Vue.use(VueRouter); + +/** + * Routes below /-/ide/: + +/project/h5bp/html5-boilerplate/blob/master +/project/h5bp/html5-boilerplate/blob/master/app/js/test.js + +/project/h5bp/html5-boilerplate/mr/123 +/project/h5bp/html5-boilerplate/mr/123/app/js/test.js + +/workspace/123 +/workspace/project/h5bp/html5-boilerplate/blob/my-special-branch +/workspace/project/h5bp/html5-boilerplate/mr/123 + +/ = /workspace + +/settings +*/ + +// Unfortunately Vue Router doesn't work without at least a fake component +// If you do only data handling +const EmptyRouterComponent = { + render(createElement) { + return createElement('div'); + }, +}; + +const router = new VueRouter({ + mode: 'history', + base: `${gon.relative_url_root}/-/ide/`, + routes: [ + { + path: '/project/:namespace/:project', + component: EmptyRouterComponent, + children: [ + { + path: ':targetmode/:branch/*', + component: EmptyRouterComponent, + }, + { + path: 'mr/:mrid', + component: EmptyRouterComponent, + }, + ], + }, + ], +}); + +router.beforeEach((to, from, next) => { + if (to.params.namespace && to.params.project) { + store.dispatch('getProjectData', { + namespace: to.params.namespace, + projectId: to.params.project, + }) + .then(() => { + const fullProjectId = `${to.params.namespace}/${to.params.project}`; + + if (to.params.branch) { + store.dispatch('getBranchData', { + projectId: fullProjectId, + branchId: to.params.branch, + }); + + store.dispatch('getFiles', { + projectId: fullProjectId, + branchId: to.params.branch, + }) + .then(() => { + if (to.params[0]) { + const treeEntry = store.state.entries[to.params[0]]; + if (treeEntry) { + store.dispatch('handleTreeEntryAction', treeEntry); + } + } + }) + .catch((e) => { + flash('Error while loading the branch files. Please try again.', 'alert', document, null, false, true); + throw e; + }); + } + }) + .catch((e) => { + flash('Error while loading the project data. Please try again.', 'alert', document, null, false, true); + throw e; + }); + } + + next(); +}); + +export default router; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js new file mode 100644 index 00000000000..cbfb3dc54f2 --- /dev/null +++ b/app/assets/javascripts/ide/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import ide from './components/ide.vue'; +import store from './stores'; +import router from './ide_router'; + +function initIde(el) { + if (!el) return null; + + return new Vue({ + el, + store, + router, + components: { + ide, + }, + render(createElement) { + return createElement('ide', { + props: { + emptyStateSvgPath: el.dataset.emptyStateSvgPath, + noChangesStateSvgPath: el.dataset.noChangesStateSvgPath, + committedStateSvgPath: el.dataset.committedStateSvgPath, + }, + }); + }, + }); +} + +const ideElement = document.getElementById('ide'); + +Vue.use(Translate); + +initIde(ideElement); diff --git a/app/assets/javascripts/ide/lib/common/disposable.js b/app/assets/javascripts/ide/lib/common/disposable.js new file mode 100644 index 00000000000..84b29bdb600 --- /dev/null +++ b/app/assets/javascripts/ide/lib/common/disposable.js @@ -0,0 +1,14 @@ +export default class Disposable { + constructor() { + this.disposers = new Set(); + } + + add(...disposers) { + disposers.forEach(disposer => this.disposers.add(disposer)); + } + + dispose() { + this.disposers.forEach(disposer => disposer.dispose()); + this.disposers.clear(); + } +} diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js new file mode 100644 index 00000000000..73cd684351c --- /dev/null +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -0,0 +1,90 @@ +/* global monaco */ +import Disposable from './disposable'; +import eventHub from '../../eventhub'; + +export default class Model { + constructor(monaco, file) { + this.monaco = monaco; + this.disposable = new Disposable(); + this.file = file; + this.content = file.content !== '' ? file.content : file.raw; + + this.disposable.add( + (this.originalModel = this.monaco.editor.createModel( + this.file.raw, + undefined, + new this.monaco.Uri(null, null, `original/${this.file.path}`), + )), + (this.model = this.monaco.editor.createModel( + this.content, + undefined, + new this.monaco.Uri(null, null, this.file.path), + )), + ); + + this.events = new Map(); + + this.updateContent = this.updateContent.bind(this); + this.dispose = this.dispose.bind(this); + + eventHub.$on(`editor.update.model.dispose.${this.file.path}`, this.dispose); + eventHub.$on( + `editor.update.model.content.${this.file.path}`, + this.updateContent, + ); + } + + get url() { + return this.model.uri.toString(); + } + + get language() { + return this.model.getModeId(); + } + + get eol() { + return this.model.getEOL() === '\n' ? 'LF' : 'CRLF'; + } + + get path() { + return this.file.path; + } + + getModel() { + return this.model; + } + + getOriginalModel() { + return this.originalModel; + } + + setValue(value) { + this.getModel().setValue(value); + } + + onChange(cb) { + this.events.set( + this.path, + this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))), + ); + } + + updateContent(content) { + this.getOriginalModel().setValue(content); + this.getModel().setValue(content); + } + + dispose() { + this.disposable.dispose(); + this.events.clear(); + + eventHub.$off( + `editor.update.model.dispose.${this.file.path}`, + this.dispose, + ); + eventHub.$off( + `editor.update.model.content.${this.file.path}`, + this.updateContent, + ); + } +} diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js new file mode 100644 index 00000000000..57d5e59a88b --- /dev/null +++ b/app/assets/javascripts/ide/lib/common/model_manager.js @@ -0,0 +1,51 @@ +import eventHub from '../../eventhub'; +import Disposable from './disposable'; +import Model from './model'; + +export default class ModelManager { + constructor(monaco) { + this.monaco = monaco; + this.disposable = new Disposable(); + this.models = new Map(); + } + + hasCachedModel(path) { + return this.models.has(path); + } + + getModel(path) { + return this.models.get(path); + } + + addModel(file) { + if (this.hasCachedModel(file.path)) { + return this.getModel(file.path); + } + + const model = new Model(this.monaco, file); + this.models.set(model.path, model); + this.disposable.add(model); + + eventHub.$on( + `editor.update.model.dispose.${file.path}`, + this.removeCachedModel.bind(this, file), + ); + + return model; + } + + removeCachedModel(file) { + this.models.delete(file.path); + + eventHub.$off( + `editor.update.model.dispose.${file.path}`, + this.removeCachedModel, + ); + } + + dispose() { + // dispose of all the models + this.disposable.dispose(); + this.models.clear(); + } +} diff --git a/app/assets/javascripts/ide/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js new file mode 100644 index 00000000000..42904774747 --- /dev/null +++ b/app/assets/javascripts/ide/lib/decorations/controller.js @@ -0,0 +1,45 @@ +export default class DecorationsController { + constructor(editor) { + this.editor = editor; + this.decorations = new Map(); + this.editorDecorations = new Map(); + } + + getAllDecorationsForModel(model) { + if (!this.decorations.has(model.url)) return []; + + const modelDecorations = this.decorations.get(model.url); + const decorations = []; + + modelDecorations.forEach(val => decorations.push(...val)); + + return decorations; + } + + addDecorations(model, decorationsKey, decorations) { + const decorationMap = this.decorations.get(model.url) || new Map(); + + decorationMap.set(decorationsKey, decorations); + + this.decorations.set(model.url, decorationMap); + + this.decorate(model); + } + + decorate(model) { + if (!this.editor.instance) return; + + const decorations = this.getAllDecorationsForModel(model); + const oldDecorations = this.editorDecorations.get(model.url) || []; + + this.editorDecorations.set( + model.url, + this.editor.instance.deltaDecorations(oldDecorations, decorations), + ); + } + + dispose() { + this.decorations.clear(); + this.editorDecorations.clear(); + } +} diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js new file mode 100644 index 00000000000..b136545ad11 --- /dev/null +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -0,0 +1,72 @@ +/* global monaco */ +import { throttle } from 'underscore'; +import DirtyDiffWorker from './diff_worker'; +import Disposable from '../common/disposable'; + +export const getDiffChangeType = (change) => { + if (change.modified) { + return 'modified'; + } else if (change.added) { + return 'added'; + } else if (change.removed) { + return 'removed'; + } + + return ''; +}; + +export const getDecorator = change => ({ + range: new monaco.Range( + change.lineNumber, + 1, + change.endLineNumber, + 1, + ), + options: { + isWholeLine: true, + linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`, + }, +}); + +export default class DirtyDiffController { + constructor(modelManager, decorationsController) { + this.disposable = new Disposable(); + this.editorSimpleWorker = null; + this.modelManager = modelManager; + this.decorationsController = decorationsController; + this.dirtyDiffWorker = new DirtyDiffWorker(); + this.throttledComputeDiff = throttle(this.computeDiff, 250); + this.decorate = this.decorate.bind(this); + + this.dirtyDiffWorker.addEventListener('message', this.decorate); + } + + attachModel(model) { + model.onChange(() => this.throttledComputeDiff(model)); + } + + computeDiff(model) { + this.dirtyDiffWorker.postMessage({ + path: model.path, + originalContent: model.getOriginalModel().getValue(), + newContent: model.getModel().getValue(), + }); + } + + reDecorate(model) { + this.decorationsController.decorate(model); + } + + decorate({ data }) { + const decorations = data.changes.map(change => getDecorator(change)); + const model = this.modelManager.getModel(data.path); + this.decorationsController.addDecorations(model, 'dirtyDiff', decorations); + } + + dispose() { + this.disposable.dispose(); + + this.dirtyDiffWorker.removeEventListener('message', this.decorate); + this.dirtyDiffWorker.terminate(); + } +} diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js new file mode 100644 index 00000000000..0e37f5c4704 --- /dev/null +++ b/app/assets/javascripts/ide/lib/diff/diff.js @@ -0,0 +1,30 @@ +import { diffLines } from 'diff'; + +// eslint-disable-next-line import/prefer-default-export +export const computeDiff = (originalContent, newContent) => { + const changes = diffLines(originalContent, newContent); + + let lineNumber = 1; + return changes.reduce((acc, change) => { + const findOnLine = acc.find(c => c.lineNumber === lineNumber); + + if (findOnLine) { + Object.assign(findOnLine, change, { + modified: true, + endLineNumber: (lineNumber + change.count) - 1, + }); + } else if ('added' in change || 'removed' in change) { + acc.push(Object.assign({}, change, { + lineNumber, + modified: undefined, + endLineNumber: (lineNumber + change.count) - 1, + })); + } + + if (!change.removed) { + lineNumber += change.count; + } + + return acc; + }, []); +}; diff --git a/app/assets/javascripts/ide/lib/diff/diff_worker.js b/app/assets/javascripts/ide/lib/diff/diff_worker.js new file mode 100644 index 00000000000..e74c4046330 --- /dev/null +++ b/app/assets/javascripts/ide/lib/diff/diff_worker.js @@ -0,0 +1,10 @@ +import { computeDiff } from './diff'; + +self.addEventListener('message', (e) => { + const data = e.data; + + self.postMessage({ + path: data.path, + changes: computeDiff(data.originalContent, data.newContent), + }); +}); diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js new file mode 100644 index 00000000000..38de2fe2b27 --- /dev/null +++ b/app/assets/javascripts/ide/lib/editor.js @@ -0,0 +1,164 @@ +import _ from 'underscore'; +import DecorationsController from './decorations/controller'; +import DirtyDiffController from './diff/controller'; +import Disposable from './common/disposable'; +import ModelManager from './common/model_manager'; +import editorOptions, { defaultEditorOptions } from './editor_options'; +import gitlabTheme from './themes/gl_theme'; + +export const clearDomElement = el => { + if (!el || !el.firstChild) return; + + while (el.firstChild) { + el.removeChild(el.firstChild); + } +}; + +export default class Editor { + static create(monaco) { + if (this.editorInstance) return this.editorInstance; + + this.editorInstance = new Editor(monaco); + + return this.editorInstance; + } + + constructor(monaco) { + this.monaco = monaco; + this.currentModel = null; + this.instance = null; + this.dirtyDiffController = null; + this.disposable = new Disposable(); + this.modelManager = new ModelManager(this.monaco); + this.decorationsController = new DecorationsController(this); + + this.setupMonacoTheme(); + + this.debouncedUpdate = _.debounce(() => { + this.updateDimensions(); + }, 200); + } + + createInstance(domElement) { + if (!this.instance) { + clearDomElement(domElement); + + this.disposable.add( + (this.instance = this.monaco.editor.create(domElement, { + ...defaultEditorOptions, + })), + (this.dirtyDiffController = new DirtyDiffController( + this.modelManager, + this.decorationsController, + )), + ); + + window.addEventListener('resize', this.debouncedUpdate, false); + } + } + + createDiffInstance(domElement) { + if (!this.instance) { + clearDomElement(domElement); + + this.disposable.add( + (this.instance = this.monaco.editor.createDiffEditor(domElement, { + ...defaultEditorOptions, + readOnly: true, + })), + ); + + window.addEventListener('resize', this.debouncedUpdate, false); + } + } + + createModel(file) { + return this.modelManager.addModel(file); + } + + attachModel(model) { + if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') { + this.instance.setModel({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + + return; + } + + this.instance.setModel(model.getModel()); + if (this.dirtyDiffController) this.dirtyDiffController.attachModel(model); + + this.currentModel = model; + + this.instance.updateOptions( + editorOptions.reduce((acc, obj) => { + Object.keys(obj).forEach(key => { + Object.assign(acc, { + [key]: obj[key](model), + }); + }); + return acc; + }, {}), + ); + + if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model); + } + + setupMonacoTheme() { + this.monaco.editor.defineTheme( + gitlabTheme.themeName, + gitlabTheme.monacoTheme, + ); + + this.monaco.editor.setTheme('gitlab'); + } + + clearEditor() { + if (this.instance) { + this.instance.setModel(null); + } + } + + dispose() { + window.removeEventListener('resize', this.debouncedUpdate); + + // catch any potential errors with disposing the error + // this is mainly for tests caused by elements not existing + try { + this.disposable.dispose(); + + this.instance = null; + } catch (e) { + this.instance = null; + + if (process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console + console.error(e); + } + } + } + + updateDimensions() { + this.instance.layout(); + } + + setPosition({ lineNumber, column }) { + this.instance.revealPositionInCenter({ + lineNumber, + column, + }); + this.instance.setPosition({ + lineNumber, + column, + }); + } + + onPositionChange(cb) { + if (!this.instance.onDidChangeCursorPosition) return; + + this.disposable.add( + this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)), + ); + } +} diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js new file mode 100644 index 00000000000..d69d4b8c615 --- /dev/null +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -0,0 +1,15 @@ +export const defaultEditorOptions = { + model: null, + readOnly: false, + contextmenu: true, + scrollBeyondLastLine: false, + minimap: { + enabled: false, + }, +}; + +export default [ + { + readOnly: model => !!model.file.file_lock, + }, +]; diff --git a/app/assets/javascripts/ide/lib/themes/gl_theme.js b/app/assets/javascripts/ide/lib/themes/gl_theme.js new file mode 100644 index 00000000000..2fc96250c7d --- /dev/null +++ b/app/assets/javascripts/ide/lib/themes/gl_theme.js @@ -0,0 +1,14 @@ +export default { + themeName: 'gitlab', + monacoTheme: { + base: 'vs', + inherit: true, + rules: [], + colors: { + 'editorLineNumber.foreground': '#CCCCCC', + 'diffEditor.insertedTextBackground': '#ddfbe6', + 'diffEditor.removedTextBackground': '#f9d7dc', + 'editor.selectionBackground': '#aad6f8', + }, + }, +}; diff --git a/app/assets/javascripts/ide/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js new file mode 100644 index 00000000000..142a220097b --- /dev/null +++ b/app/assets/javascripts/ide/monaco_loader.js @@ -0,0 +1,16 @@ +import monacoContext from 'monaco-editor/dev/vs/loader'; + +monacoContext.require.config({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, +}); + +// ignore CDN config and use local assets path for service worker which cannot be cross-domain +const relativeRootPath = (gon && gon.relative_url_root) || ''; +const monacoPath = `${relativeRootPath}/assets/webpack/monaco-editor/vs`; +window.MonacoEnvironment = { getWorkerUrl: () => `${monacoPath}/base/worker/workerMain.js` }; + +// eslint-disable-next-line no-underscore-dangle +window.__monaco_context__ = monacoContext; +export default monacoContext.require; diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js new file mode 100644 index 00000000000..5f1fb6cf843 --- /dev/null +++ b/app/assets/javascripts/ide/services/index.js @@ -0,0 +1,55 @@ +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); + } + + if (file.raw) { + return Promise.resolve(file.raw); + } + + return Vue.http.get(file.rawPath, { params: { format: 'json' } }) + .then(res => res.text()); + }, + getProjectData(namespace, project) { + return Api.project(`${namespace}/${project}`); + }, + getBranchData(projectId, currentBranchId) { + return Api.branchSingle(projectId, currentBranchId); + }, + 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); + }, + getTreeLastCommit(endpoint) { + return Vue.http.get(endpoint, { + params: { + format: 'json', + }, + }); + }, + getFiles(projectUrl, branchId) { + const url = `${projectUrl}/files/${branchId}`; + return Vue.http.get(url, { + params: { + format: 'json', + }, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js new file mode 100644 index 00000000000..7e920aa9f30 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions.js @@ -0,0 +1,121 @@ +import Vue from 'vue'; +import { visitUrl } from '~/lib/utils/url_utility'; +import flash from '~/flash'; +import * as types from './mutation_types'; +import FilesDecoratorWorker from './workers/files_decorator_worker'; + +export const redirectToUrl = (_, url) => visitUrl(url); + +export const setInitialData = ({ commit }, data) => + commit(types.SET_INITIAL_DATA, data); + +export const discardAllChanges = ({ state, commit, dispatch }) => { + state.changedFiles.forEach(file => { + commit(types.DISCARD_FILE_CHANGES, file.path); + + if (file.tempFile) { + dispatch('closeFile', file.path); + } + }); + + commit(types.REMOVE_ALL_CHANGES_FILES); +}; + +export const closeAllFiles = ({ state, dispatch }) => { + state.openFiles.forEach(file => dispatch('closeFile', file.path)); +}; + +export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { + if (side === 'left') { + commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed); + } else { + commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed); + } +}; + +export const setResizingStatus = ({ commit }, resizing) => { + commit(types.SET_RESIZING_STATUS, resizing); +}; + +export const createTempEntry = ( + { state, commit, dispatch }, + { branchId, name, type, content = '', base64 = false }, +) => + new Promise(resolve => { + const worker = new FilesDecoratorWorker(); + const fullName = + name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; + + if (state.entries[name]) { + flash( + `The name "${name + .split('/') + .pop()}" is already taken in this directory.`, + 'alert', + document, + null, + false, + true, + ); + + resolve(); + + return null; + } + + worker.addEventListener('message', ({ data }) => { + const { file } = data; + + worker.terminate(); + + commit(types.CREATE_TMP_ENTRY, { + data, + projectId: state.currentProjectId, + branchId, + }); + + if (type === 'blob') { + commit(types.TOGGLE_FILE_OPEN, file.path); + commit(types.ADD_FILE_TO_CHANGED, file.path); + dispatch('setFileActive', file.path); + } + + resolve(file); + }); + + worker.postMessage({ + data: [fullName], + projectId: state.currentProjectId, + branchId, + type, + tempFile: true, + base64, + content, + }); + + return null; + }); + +export const scrollToTab = () => { + Vue.nextTick(() => { + const tabs = document.getElementById('tabs'); + + if (tabs) { + const tabEl = tabs.querySelector('.active .repo-tab'); + + tabEl.focus(); + } + }); +}; + +export const updateViewer = ({ commit }, viewer) => { + commit(types.UPDATE_VIEWER, viewer); +}; + +export const updateDelayViewerUpdated = ({ commit }, delay) => { + commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay); +}; + +export * from './actions/tree'; +export * from './actions/file'; +export * from './actions/project'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js new file mode 100644 index 00000000000..ddc4b757bf9 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -0,0 +1,146 @@ +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import flash from '~/flash'; +import eventHub from '../../eventhub'; +import service from '../../services'; +import * as types from '../mutation_types'; +import router from '../../ide_router'; +import { setPageTitle } from '../utils'; + +export const closeFile = ({ commit, state, getters, dispatch }, path) => { + const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path); + const file = state.entries[path]; + const fileWasActive = file.active; + + commit(types.TOGGLE_FILE_OPEN, path); + commit(types.SET_FILE_ACTIVE, { path, active: false }); + + if (state.openFiles.length > 0 && fileWasActive) { + const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; + const nextFileToOpen = state.entries[state.openFiles[nextIndexToOpen].path]; + + router.push(`/project${nextFileToOpen.url}`); + } else if (!state.openFiles.length) { + router.push(`/project/${file.projectId}/tree/${file.branchId}/`); + } + + eventHub.$emit(`editor.update.model.dispose.${file.path}`); +}; + +export const setFileActive = ({ commit, state, getters, dispatch }, path) => { + const file = state.entries[path]; + const currentActiveFile = getters.activeFile; + + if (file.active) return; + + if (currentActiveFile) { + commit(types.SET_FILE_ACTIVE, { + path: currentActiveFile.path, + active: false, + }); + } + + commit(types.SET_FILE_ACTIVE, { path, active: true }); + dispatch('scrollToTab'); + + commit(types.SET_CURRENT_PROJECT, file.projectId); + commit(types.SET_CURRENT_BRANCH, file.branchId); +}; + +export const getFileData = ({ state, commit, dispatch }, file) => { + commit(types.TOGGLE_LOADING, { entry: file }); + + return 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.path); + dispatch('setFileActive', file.path); + commit(types.TOGGLE_LOADING, { entry: file }); + }) + .catch(() => { + commit(types.TOGGLE_LOADING, { entry: file }); + flash( + 'Error loading file data. Please try again.', + 'alert', + document, + null, + false, + true, + ); + }); +}; + +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.', + 'alert', + document, + null, + false, + true, + ), + ); + +export const changeFileContent = ({ state, commit }, { path, content }) => { + const file = state.entries[path]; + commit(types.UPDATE_FILE_CONTENT, { path, content }); + + const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); + + if (file.changed && indexOfChangedFile === -1) { + commit(types.ADD_FILE_TO_CHANGED, path); + } else if (!file.changed && indexOfChangedFile !== -1) { + commit(types.REMOVE_FILE_FROM_CHANGED, path); + } +}; + +export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => { + if (getters.activeFile) { + commit(types.SET_FILE_LANGUAGE, { file: getters.activeFile, fileLanguage }); + } +}; + +export const setFileEOL = ({ getters, commit }, { eol }) => { + if (getters.activeFile) { + commit(types.SET_FILE_EOL, { file: getters.activeFile, eol }); + } +}; + +export const setEditorPosition = ( + { getters, commit }, + { editorRow, editorColumn }, +) => { + if (getters.activeFile) { + commit(types.SET_FILE_POSITION, { + file: getters.activeFile, + editorRow, + editorColumn, + }); + } +}; + +export const discardFileChanges = ({ state, commit }, path) => { + const file = state.entries[path]; + + commit(types.DISCARD_FILE_CHANGES, path); + commit(types.REMOVE_FILE_FROM_CHANGED, path); + + if (file.tempFile && file.opened) { + commit(types.TOGGLE_FILE_OPEN, path); + } + + eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); +}; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js new file mode 100644 index 00000000000..b3882cb8d21 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -0,0 +1,49 @@ +import flash from '~/flash'; +import service from '../../services'; +import * as types from '../mutation_types'; + +export const getProjectData = ( + { commit, state, dispatch }, + { namespace, projectId, force = false } = {}, +) => new Promise((resolve, reject) => { + if (!state.projects[`${namespace}/${projectId}`] || force) { + commit(types.TOGGLE_LOADING, { entry: state }); + service.getProjectData(namespace, projectId) + .then(res => res.data) + .then((data) => { + commit(types.TOGGLE_LOADING, { entry: state }); + commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); + if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); + resolve(data); + }) + .catch(() => { + flash('Error loading project data. Please try again.', 'alert', document, null, false, true); + reject(new Error(`Project not loaded ${namespace}/${projectId}`)); + }); + } else { + resolve(state.projects[`${namespace}/${projectId}`]); + } +}); + +export const getBranchData = ( + { commit, state, dispatch }, + { projectId, branchId, force = false } = {}, +) => new Promise((resolve, reject) => { + if ((typeof state.projects[`${projectId}`] === 'undefined' || + !state.projects[`${projectId}`].branches[branchId]) + || force) { + service.getBranchData(`${projectId}`, branchId) + .then(({ data }) => { + const { id } = data.commit; + commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data }); + commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); + resolve(data); + }) + .catch(() => { + flash('Error loading branch data. Please try again.', 'alert', document, null, false, true); + reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); + }); + } else { + resolve(state.projects[`${projectId}`].branches[branchId]); + } +}); diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js new file mode 100644 index 00000000000..70a969a0325 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -0,0 +1,93 @@ +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import flash from '~/flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import { + findEntry, +} from '../utils'; +import FilesDecoratorWorker from '../workers/files_decorator_worker'; + +export const toggleTreeOpen = ({ commit, dispatch }, path) => { + commit(types.TOGGLE_TREE_OPEN, path); +}; + +export const handleTreeEntryAction = ({ commit, dispatch }, row) => { + if (row.type === 'tree') { + dispatch('toggleTreeOpen', row.path); + } else if (row.type === 'blob' && (row.opened || row.changed)) { + if (row.changed && !row.opened) { + commit(types.TOGGLE_FILE_OPEN, row.path); + } + + dispatch('setFileActive', row.path); + } else { + dispatch('getFileData', row); + } +}; + +export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { + if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; + + service.getTreeLastCommit(tree.lastCommitPath) + .then((res) => { + const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; + + commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); + + return res.json(); + }) + .then((data) => { + data.forEach((lastCommit) => { + const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); + + if (entry) { + commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); + } + }); + + dispatch('getLastCommitData', tree); + }) + .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true)); +}; + +export const getFiles = ( + { state, commit, dispatch }, + { projectId, branchId } = {}, +) => new Promise((resolve, reject) => { + if (!state.trees[`${projectId}/${branchId}`]) { + const selectedProject = state.projects[projectId]; + commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); + + service + .getFiles(selectedProject.web_url, branchId) + .then(res => res.json()) + .then((data) => { + const worker = new FilesDecoratorWorker(); + worker.addEventListener('message', (e) => { + const { entries, treeList } = e.data; + const selectedTree = state.trees[`${projectId}/${branchId}`]; + + commit(types.SET_ENTRIES, entries); + commit(types.SET_DIRECTORY_DATA, { treePath: `${projectId}/${branchId}`, data: treeList }); + commit(types.TOGGLE_LOADING, { entry: selectedTree, forceValue: false }); + + worker.terminate(); + + resolve(); + }); + + worker.postMessage({ + data, + projectId, + branchId, + }); + }) + .catch((e) => { + flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); + reject(e); + }); + } else { + resolve(); + } +}); + diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js new file mode 100644 index 00000000000..eba325a31df --- /dev/null +++ b/app/assets/javascripts/ide/stores/getters.js @@ -0,0 +1,30 @@ +export const activeFile = state => + state.openFiles.find(file => file.active) || null; + +export const addedFiles = state => state.changedFiles.filter(f => f.tempFile); + +export const modifiedFiles = state => + state.changedFiles.filter(f => !f.tempFile); + +export const projectsWithTrees = state => + Object.keys(state.projects).map(projectId => { + const project = state.projects[projectId]; + + return { + ...project, + branches: Object.keys(project.branches).map(branchId => { + const branch = project.branches[branchId]; + + return { + ...branch, + tree: state.trees[branch.treeId], + }; + }), + }; + }); + +// eslint-disable-next-line no-confusing-arrow +export const currentIcon = state => + state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; + +export const hasChanges = state => !!state.changedFiles.length; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js new file mode 100644 index 00000000000..7c82ce7976b --- /dev/null +++ b/app/assets/javascripts/ide/stores/index.js @@ -0,0 +1,19 @@ +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'; +import commitModule from './modules/commit'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: state(), + actions, + mutations, + getters, + modules: { + commit: commitModule, + }, +}); diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js new file mode 100644 index 00000000000..2e1aea9a399 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -0,0 +1,218 @@ +import $ from 'jquery'; +import { sprintf, __ } from '~/locale'; +import flash from '~/flash'; +import { stripHtml } from '~/lib/utils/text_utility'; +import * as rootTypes from '../../mutation_types'; +import { createCommitPayload, createNewMergeRequestUrl } from '../../utils'; +import router from '../../../ide_router'; +import service from '../../../services'; +import * as types from './mutation_types'; +import * as consts from './constants'; +import eventHub from '../../../eventhub'; + +export const updateCommitMessage = ({ commit }, message) => { + commit(types.UPDATE_COMMIT_MESSAGE, message); +}; + +export const discardDraft = ({ commit }) => { + commit(types.UPDATE_COMMIT_MESSAGE, ''); +}; + +export const updateCommitAction = ({ commit }, commitAction) => { + commit(types.UPDATE_COMMIT_ACTION, commitAction); +}; + +export const updateBranchName = ({ commit }, branchName) => { + commit(types.UPDATE_NEW_BRANCH_NAME, branchName); +}; + +export const setLastCommitMessage = ({ rootState, commit }, data) => { + const currentProject = rootState.projects[rootState.currentProjectId]; + const commitStats = data.stats + ? sprintf(__('with %{additions} additions, %{deletions} deletions.'), { + additions: data.stats.additions, + deletions: data.stats.deletions, + }) + : ''; + const commitMsg = sprintf( + __('Your changes have been committed. Commit %{commitId} %{commitStats}'), + { + commitId: `<a href="${currentProject.web_url}/commit/${ + data.short_id + }" class="commit-sha">${data.short_id}</a>`, + commitStats, + }, + false, + ); + + commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true }); +}; + +export const checkCommitStatus = ({ rootState }) => + service + .getBranchData(rootState.currentProjectId, rootState.currentBranchId) + .then(({ data }) => { + const { id } = data.commit; + const selectedBranch = + rootState.projects[rootState.currentProjectId].branches[ + rootState.currentBranchId + ]; + + if (selectedBranch.workingReference !== id) { + return true; + } + + return false; + }) + .catch(() => + flash( + __('Error checking branch data. Please try again.'), + 'alert', + document, + null, + false, + true, + ), + ); + +export const updateFilesAfterCommit = ( + { commit, dispatch, state, rootState, rootGetters }, + { data, branch }, +) => { + const selectedProject = rootState.projects[rootState.currentProjectId]; + const lastCommit = { + commit_path: `${selectedProject.web_url}/commit/${data.id}`, + commit: { + id: data.id, + message: data.message, + authored_date: data.committed_date, + author_name: data.committer_name, + }, + }; + + commit( + rootTypes.SET_BRANCH_WORKING_REFERENCE, + { + projectId: rootState.currentProjectId, + branchId: rootState.currentBranchId, + reference: data.id, + }, + { root: true }, + ); + + rootState.changedFiles.forEach(entry => { + commit( + rootTypes.SET_LAST_COMMIT_DATA, + { + entry, + lastCommit, + }, + { root: true }, + ); + + eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content); + + commit( + rootTypes.SET_FILE_RAW_DATA, + { + file: entry, + raw: entry.content, + }, + { root: true }, + ); + + commit( + rootTypes.TOGGLE_FILE_CHANGED, + { + file: entry, + changed: false, + }, + { root: true }, + ); + }); + + commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true }); + + if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { + router.push( + `/project/${rootState.currentProjectId}/blob/${branch}/${ + rootGetters.activeFile.path + }`, + ); + } + + dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH); +}; + +export const commitChanges = ({ + commit, + state, + getters, + dispatch, + rootState, +}) => { + const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH; + const payload = createCommitPayload( + getters.branchName, + newBranch, + state, + rootState, + ); + const getCommitStatus = newBranch + ? Promise.resolve(false) + : dispatch('checkCommitStatus'); + + commit(types.UPDATE_LOADING, true); + + return getCommitStatus + .then( + branchChanged => + new Promise(resolve => { + if (branchChanged) { + // show the modal with a Bootstrap call + $('#ide-create-branch-modal').modal('show'); + } else { + resolve(); + } + }), + ) + .then(() => service.commit(rootState.currentProjectId, payload)) + .then(({ data }) => { + commit(types.UPDATE_LOADING, false); + + if (!data.short_id) { + flash(data.message, 'alert', document, null, false, true); + return; + } + + dispatch('setLastCommitMessage', data); + dispatch('updateCommitMessage', ''); + + if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) { + dispatch( + 'redirectToUrl', + createNewMergeRequestUrl( + rootState.projects[rootState.currentProjectId].web_url, + getters.branchName, + rootState.currentBranchId, + ), + { root: true }, + ); + } else { + dispatch('updateFilesAfterCommit', { + data, + branch: getters.branchName, + }); + } + }) + .catch(err => { + let errMsg = __('Error committing changes. Please try again.'); + if (err.response.data && err.response.data.message) { + errMsg += ` (${stripHtml(err.response.data.message)})`; + } + flash(errMsg, 'alert', document, null, false, true); + window.dispatchEvent(new Event('resize')); + + commit(types.UPDATE_LOADING, false); + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js new file mode 100644 index 00000000000..230b0a3d9b5 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js @@ -0,0 +1,3 @@ +export const COMMIT_TO_CURRENT_BRANCH = '1'; +export const COMMIT_TO_NEW_BRANCH = '2'; +export const COMMIT_TO_NEW_BRANCH_MR = '3'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js new file mode 100644 index 00000000000..f7cdd6adb0c --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -0,0 +1,24 @@ +import * as consts from './constants'; + +export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; + +export const commitButtonDisabled = (state, getters, rootState) => + getters.discardDraftButtonDisabled || !rootState.changedFiles.length; + +export const newBranchName = (state, _, rootState) => + `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`; + +export const branchName = (state, getters, rootState) => { + if ( + state.commitAction === consts.COMMIT_TO_NEW_BRANCH || + state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR + ) { + if (state.newBranchName === '') { + return getters.newBranchName; + } + + return state.newBranchName; + } + + return rootState.currentBranchId; +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/index.js b/app/assets/javascripts/ide/stores/modules/commit/index.js new file mode 100644 index 00000000000..3bf65b02847 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/index.js @@ -0,0 +1,12 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; +import * as getters from './getters'; + +export default { + namespaced: true, + state: state(), + mutations, + actions, + getters, +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js new file mode 100644 index 00000000000..9221f054e9f --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js @@ -0,0 +1,4 @@ +export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE'; +export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION'; +export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME'; +export const UPDATE_LOADING = 'UPDATE_LOADING'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js new file mode 100644 index 00000000000..797357e3df9 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js @@ -0,0 +1,24 @@ +import * as types from './mutation_types'; + +export default { + [types.UPDATE_COMMIT_MESSAGE](state, commitMessage) { + Object.assign(state, { + commitMessage, + }); + }, + [types.UPDATE_COMMIT_ACTION](state, commitAction) { + Object.assign(state, { + commitAction, + }); + }, + [types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) { + Object.assign(state, { + newBranchName, + }); + }, + [types.UPDATE_LOADING](state, submitCommitLoading) { + Object.assign(state, { + submitCommitLoading, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js new file mode 100644 index 00000000000..8dae50961b0 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/state.js @@ -0,0 +1,6 @@ +export default () => ({ + commitMessage: '', + commitAction: '1', + newBranchName: '', + submitCommitLoading: false, +}); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js new file mode 100644 index 00000000000..e28f190897c --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -0,0 +1,43 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; +export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; +export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG'; +export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED'; +export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED'; +export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS'; + +// Project Mutation Types +export const SET_PROJECT = 'SET_PROJECT'; +export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; +export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; + +// Branch Mutation Types +export const SET_BRANCH = 'SET_BRANCH'; +export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; +export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; + +// Tree mutation types +export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; +export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; +export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL'; +export const CREATE_TREE = 'CREATE_TREE'; +export const REMOVE_ALL_CHANGES_FILES = 'REMOVE_ALL_CHANGES_FILES'; + +// 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 SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; +export const SET_FILE_POSITION = 'SET_FILE_POSITION'; +export const SET_FILE_EOL = 'SET_FILE_EOL'; +export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; +export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; +export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED'; +export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED'; +export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; +export const SET_ENTRIES = 'SET_ENTRIES'; +export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY'; +export const UPDATE_VIEWER = 'UPDATE_VIEWER'; +export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js new file mode 100644 index 00000000000..da41fc9285c --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -0,0 +1,106 @@ +import * as types from './mutation_types'; +import projectMutations from './mutations/project'; +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.TOGGLE_LOADING](state, { entry, forceValue = undefined }) { + if (entry.path) { + Object.assign(state.entries[entry.path], { + loading: + forceValue !== undefined + ? forceValue + : !state.entries[entry.path].loading, + }); + } else { + Object.assign(entry, { + loading: forceValue !== undefined ? forceValue : !entry.loading, + }); + } + }, + [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) { + Object.assign(state, { + leftPanelCollapsed: collapsed, + }); + }, + [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) { + Object.assign(state, { + rightPanelCollapsed: collapsed, + }); + }, + [types.SET_RESIZING_STATUS](state, resizing) { + Object.assign(state, { + panelResizing: resizing, + }); + }, + [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) { + Object.assign(entry.lastCommit, { + id: lastCommit.commit.id, + url: lastCommit.commit_path, + message: lastCommit.commit.message, + author: lastCommit.commit.author_name, + updatedAt: lastCommit.commit.authored_date, + }); + }, + [types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) { + Object.assign(state, { + lastCommitMsg, + }); + }, + [types.SET_ENTRIES](state, entries) { + Object.assign(state, { + entries, + }); + }, + [types.CREATE_TMP_ENTRY](state, { data, projectId, branchId }) { + Object.keys(data.entries).reduce((acc, key) => { + const entry = data.entries[key]; + const foundEntry = state.entries[key]; + + if (!foundEntry) { + Object.assign(state.entries, { + [key]: entry, + }); + } else { + const tree = entry.tree.filter( + f => foundEntry.tree.find(e => e.path === f.path) === undefined, + ); + Object.assign(foundEntry, { + tree: foundEntry.tree.concat(tree), + }); + } + + return acc.concat(key); + }, []); + + const foundEntry = state.trees[`${projectId}/${branchId}`].tree.find( + e => e.path === data.treeList[0].path, + ); + + if (!foundEntry) { + Object.assign(state.trees[`${projectId}/${branchId}`], { + tree: state.trees[`${projectId}/${branchId}`].tree.concat( + data.treeList, + ), + }); + } + }, + [types.UPDATE_VIEWER](state, viewer) { + Object.assign(state, { + viewer, + }); + }, + [types.UPDATE_DELAY_VIEWER_CHANGE](state, delayViewerUpdated) { + Object.assign(state, { + delayViewerUpdated, + }); + }, + ...projectMutations, + ...fileMutations, + ...treeMutations, + ...branchMutations, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js new file mode 100644 index 00000000000..2972ba5e38e --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/branch.js @@ -0,0 +1,26 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_BRANCH](state, currentBranchId) { + Object.assign(state, { + currentBranchId, + }); + }, + [types.SET_BRANCH](state, { projectPath, branchName, branch }) { + Object.assign(state.projects[projectPath], { + branches: { + [branchName]: { + ...branch, + treeId: `${projectPath}/${branchName}`, + active: true, + workingReference: '', + }, + }, + }); + }, + [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) { + Object.assign(state.projects[projectId].branches[branchId], { + workingReference: reference, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js new file mode 100644 index 00000000000..2500f13db7c --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -0,0 +1,83 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_FILE_ACTIVE](state, { path, active }) { + Object.assign(state.entries[path], { + active, + }); + }, + [types.TOGGLE_FILE_OPEN](state, path) { + Object.assign(state.entries[path], { + opened: !state.entries[path].opened, + }); + + if (state.entries[path].opened) { + state.openFiles.push(state.entries[path]); + } else { + Object.assign(state, { + openFiles: state.openFiles.filter(f => f.path !== path), + }); + } + }, + [types.SET_FILE_DATA](state, { data, file }) { + Object.assign(state.entries[file.path], { + id: data.id, + blamePath: data.blame_path, + commitsPath: data.commits_path, + permalink: data.permalink, + rawPath: data.raw_path, + binary: data.binary, + renderError: data.render_error, + }); + }, + [types.SET_FILE_RAW_DATA](state, { file, raw }) { + Object.assign(state.entries[file.path], { + raw, + }); + }, + [types.UPDATE_FILE_CONTENT](state, { path, content }) { + const changed = content !== state.entries[path].raw; + + Object.assign(state.entries[path], { + content, + changed, + }); + }, + [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) { + Object.assign(state.entries[file.path], { + fileLanguage, + }); + }, + [types.SET_FILE_EOL](state, { file, eol }) { + Object.assign(state.entries[file.path], { + eol, + }); + }, + [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) { + Object.assign(state.entries[file.path], { + editorRow, + editorColumn, + }); + }, + [types.DISCARD_FILE_CHANGES](state, path) { + Object.assign(state.entries[path], { + content: state.entries[path].raw, + changed: false, + }); + }, + [types.ADD_FILE_TO_CHANGED](state, path) { + Object.assign(state, { + changedFiles: state.changedFiles.concat(state.entries[path]), + }); + }, + [types.REMOVE_FILE_FROM_CHANGED](state, path) { + Object.assign(state, { + changedFiles: state.changedFiles.filter(f => f.path !== path), + }); + }, + [types.TOGGLE_FILE_CHANGED](state, { file, changed }) { + Object.assign(state.entries[file.path], { + changed, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js new file mode 100644 index 00000000000..2816562a919 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -0,0 +1,23 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_PROJECT](state, currentProjectId) { + Object.assign(state, { + currentProjectId, + }); + }, + [types.SET_PROJECT](state, { projectPath, project }) { + // Add client side properties + Object.assign(project, { + tree: [], + branches: {}, + active: true, + }); + + Object.assign(state, { + projects: Object.assign({}, state.projects, { + [projectPath]: project, + }), + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js new file mode 100644 index 00000000000..7f7e470c9bb --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -0,0 +1,38 @@ +import * as types from '../mutation_types'; + +export default { + [types.TOGGLE_TREE_OPEN](state, path) { + Object.assign(state.entries[path], { + opened: !state.entries[path].opened, + }); + }, + [types.CREATE_TREE](state, { treePath }) { + Object.assign(state, { + trees: Object.assign({}, state.trees, { + [treePath]: { + tree: [], + loading: true, + }, + }), + }); + }, + [types.SET_DIRECTORY_DATA](state, { data, treePath }) { + Object.assign(state, { + trees: Object.assign(state.trees, { + [treePath]: { + tree: data, + }, + }), + }); + }, + [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { + Object.assign(tree, { + lastCommitPath: url, + }); + }, + [types.REMOVE_ALL_CHANGES_FILES](state) { + Object.assign(state, { + changedFiles: [], + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js new file mode 100644 index 00000000000..6110f54951c --- /dev/null +++ b/app/assets/javascripts/ide/stores/state.js @@ -0,0 +1,19 @@ +export default () => ({ + currentProjectId: '', + currentBranchId: '', + changedFiles: [], + endpoints: {}, + lastCommitMsg: '', + lastCommitPath: '', + loading: false, + openFiles: [], + parentTreeUrl: '', + trees: {}, + projects: {}, + leftPanelCollapsed: false, + rightPanelCollapsed: false, + panelResizing: false, + entries: {}, + viewer: 'editor', + delayViewerUpdated: false, +}); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js new file mode 100644 index 00000000000..487ea1ead8e --- /dev/null +++ b/app/assets/javascripts/ide/stores/utils.js @@ -0,0 +1,125 @@ +export const dataStructure = () => ({ + id: '', + key: '', + type: '', + projectId: '', + branchId: '', + name: '', + url: '', + path: '', + tempFile: false, + tree: [], + loading: false, + opened: false, + active: false, + changed: false, + lastCommitPath: '', + lastCommit: { + id: '', + url: '', + message: '', + updatedAt: '', + author: '', + }, + blamePath: '', + commitsPath: '', + permalink: '', + rawPath: '', + binary: false, + html: '', + raw: '', + content: '', + parentTreeUrl: '', + renderError: false, + base64: false, + editorRow: 1, + editorColumn: 1, + fileLanguage: '', + eol: '', +}); + +export const decorateData = (entity) => { + const { + id, + projectId, + branchId, + type, + url, + name, + path, + renderError, + content = '', + tempFile = false, + active = false, + opened = false, + changed = false, + parentTreeUrl = '', + base64 = false, + + file_lock, + + } = entity; + + return { + ...dataStructure(), + id, + projectId, + branchId, + key: `${name}-${type}-${id}`, + type, + name, + url, + path, + tempFile, + opened, + active, + parentTreeUrl, + changed, + renderError, + content, + base64, + + file_lock, + + }; +}; + +export const findEntry = (tree, type, name, prop = 'name') => tree.find( + f => f.type === type && f[prop] === name, +); + +export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); + +export const setPageTitle = (title) => { + document.title = title; +}; + +export const createCommitPayload = (branch, newBranch, state, rootState) => ({ + branch, + commit_message: state.commitMessage, + actions: rootState.changedFiles.map(f => ({ + action: f.tempFile ? 'create' : 'update', + file_path: f.path, + content: f.content, + encoding: f.base64 ? 'base64' : 'text', + })), + start_branch: newBranch ? rootState.currentBranchId : undefined, +}); + +export const createNewMergeRequestUrl = (projectUrl, source, target) => + `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}`; + +const sortTreesByTypeAndName = (a, b) => { + if (a.type === 'tree' && b.type === 'blob') { + return -1; + } else if (a.type === 'blob' && b.type === 'tree') { + return 1; + } + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; +}; + +export const sortTree = sortedTree => sortedTree.map(entity => Object.assign(entity, { + tree: entity.tree.length ? sortTree(entity.tree) : [], +})).sort(sortTreesByTypeAndName); diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js new file mode 100644 index 00000000000..e959130300b --- /dev/null +++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js @@ -0,0 +1,91 @@ +import { + decorateData, + sortTree, +} from '../utils'; + +self.addEventListener('message', (e) => { + const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; + + const treeList = []; + let file; + const entries = data.reduce((acc, path) => { + const pathSplit = path.split('/'); + const blobName = pathSplit.pop().trim(); + + if (pathSplit.length > 0) { + pathSplit.reduce((pathAcc, folderName) => { + const parentFolder = acc[pathAcc[pathAcc.length - 1]]; + const folderPath = `${(parentFolder ? `${parentFolder.path}/` : '')}${folderName}`; + const foundEntry = acc[folderPath]; + + if (!foundEntry) { + const tree = decorateData({ + projectId, + branchId, + id: folderPath, + name: folderName, + path: folderPath, + url: `/${projectId}/tree/${branchId}/${folderPath}`, + type: 'tree', + parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, + tempFile, + changed: tempFile, + opened: tempFile, + }); + + Object.assign(acc, { + [folderPath]: tree, + }); + + if (parentFolder) { + parentFolder.tree.push(tree); + } else { + treeList.push(tree); + } + + pathAcc.push(tree.path); + } else { + pathAcc.push(foundEntry.path); + } + + return pathAcc; + }, []); + } + + if (blobName !== '') { + const fileFolder = acc[pathSplit.join('/')]; + file = decorateData({ + projectId, + branchId, + id: path, + name: blobName, + path, + url: `/${projectId}/blob/${branchId}/${path}`, + type: 'blob', + parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, + tempFile, + changed: tempFile, + content, + base64, + }); + + Object.assign(acc, { + [path]: file, + }); + + if (fileFolder) { + fileFolder.tree.push(file); + } else { + treeList.push(file); + } + } + + return acc; + }, {}); + + self.postMessage({ + entries, + treeList: sortTree(treeList), + file, + }); +}); diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index 2d015ef086b..93cb83b3a4c 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -20,7 +20,7 @@ width: 100%; } - $image-widths: 250 306 394 430; + $image-widths: 80 250 306 394 430; @each $width in $image-widths { &.svg-#{$width} { img, @@ -39,12 +39,28 @@ svg { fill: currentColor; - &.s8 { @include svg-size(8px); } - &.s12 { @include svg-size(12px); } - &.s16 { @include svg-size(16px); } - &.s18 { @include svg-size(18px); } - &.s24 { @include svg-size(24px); } - &.s32 { @include svg-size(32px); } - &.s48 { @include svg-size(48px); } - &.s72 { @include svg-size(72px); } + &.s8 { + @include svg-size(8px); + } + &.s12 { + @include svg-size(12px); + } + &.s16 { + @include svg-size(16px); + } + &.s18 { + @include svg-size(18px); + } + &.s24 { + @include svg-size(24px); + } + &.s32 { + @include svg-size(32px); + } + &.s48 { + @include svg-size(48px); + } + &.s72 { + @include svg-size(72px); + } } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 8265b8370f7..7a8fbfc517d 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -19,6 +19,7 @@ .ide-view { display: flex; height: calc(100vh - #{$header-height}); + margin-top: 40px; color: $almost-black; border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; @@ -28,6 +29,11 @@ max-width: 250px; } } + + .file-status-icon { + width: 10px; + height: 10px; + } } .ide-file-list { @@ -40,31 +46,41 @@ background: $white-normal; } - .repo-file-name { + .ide-file-name { + flex: 1; white-space: nowrap; text-overflow: ellipsis; + + svg { + vertical-align: middle; + margin-right: 2px; + } + + .loading-container { + margin-right: 4px; + display: inline-block; + } } - .unsaved-icon { - color: $indigo-700; - float: right; - font-size: smaller; - line-height: 20px; + .ide-file-changed-icon { + margin-left: auto; } - .repo-new-btn { + .ide-new-btn { display: none; - margin-top: -4px; margin-bottom: -4px; + margin-right: -8px; } &:hover { - .repo-new-btn { + .ide-new-btn { display: block; } + } - .unsaved-icon { - display: none; + &.folder { + svg { + fill: $gl-text-color-secondary; } } } @@ -79,10 +95,10 @@ } } -.multi-file-table-name, -.multi-file-table-col-commit-message { +.file-name, +.file-col-commit-message { + display: flex; overflow: visible; - max-width: 0; padding: 6px 12px; } @@ -99,21 +115,6 @@ } } -table.table tr td.multi-file-table-name { - width: 350px; - padding: 6px 12px; - - svg { - vertical-align: middle; - margin-right: 2px; - } - - .loading-container { - margin-right: 4px; - display: inline-block; - } -} - .multi-file-table-col-commit-message { white-space: nowrap; width: 50%; @@ -129,13 +130,35 @@ table.table tr td.multi-file-table-name { .multi-file-tabs { display: flex; - overflow-x: auto; background-color: $white-normal; box-shadow: inset 0 -1px $white-dark; - > li { + > ul { + display: flex; + overflow-x: auto; + } + + li { position: relative; } + + .dropdown { + display: flex; + margin-left: auto; + margin-bottom: 1px; + padding: 0 $grid-size; + border-left: 1px solid $white-dark; + background-color: $white-light; + + &.shadow { + box-shadow: 0 0 10px $dropdown-shadow-color; + } + + .btn { + margin-top: auto; + margin-bottom: auto; + } + } } .multi-file-tab { @@ -160,20 +183,32 @@ table.table tr td.multi-file-table-name { position: absolute; right: 8px; top: 50%; + width: 16px; + height: 16px; padding: 0; background: none; border: 0; - font-size: $gl-font-size; - color: $gray-darkest; + border-radius: $border-radius-default; + color: $theme-gray-900; transform: translateY(-50%); - &:not(.modified):hover, - &:not(.modified):focus { - color: $hint-color; + svg { + position: relative; + top: -1px; } - &.modified { - color: $indigo-700; + &:hover { + background-color: $theme-gray-200; + } + + &:focus { + background-color: $blue-500; + color: $white-light; + outline: 0; + + svg { + fill: currentColor; + } } } @@ -192,6 +227,70 @@ table.table tr td.multi-file-table-name { .vertical-center { min-height: auto; } + + .monaco-editor .lines-content .cigr { + display: none; + } + + .monaco-diff-editor.vs { + .editor.modified { + box-shadow: none; + } + + .diagonal-fill { + display: none !important; + } + + .diffOverview { + background-color: $white-light; + border-left: 1px solid $white-dark; + cursor: ns-resize; + } + + .diffViewport { + display: none; + } + + .char-insert { + background-color: $line-added-dark; + } + + .char-delete { + background-color: $line-removed-dark; + } + + .line-numbers { + color: $black-transparent; + } + + .view-overlays { + .line-insert { + background-color: $line-added; + } + + .line-delete { + background-color: $line-removed; + } + } + + .margin { + background-color: $gray-light; + border-right: 1px solid $white-normal; + + .line-insert { + border-right: 1px solid $line-added-dark; + } + + .line-delete { + border-right: 1px solid $line-removed-dark; + } + } + + .margin-view-overlays .insert-sign, + .margin-view-overlays .delete-sign { + opacity: 0.4; + } + } } .multi-file-editor-holder { @@ -252,7 +351,7 @@ table.table tr td.multi-file-table-name { display: flex; position: relative; flex-direction: column; - width: 290px; + width: 340px; padding: 0; background-color: $gray-light; padding-right: 3px; @@ -350,6 +449,11 @@ table.table tr td.multi-file-table-name { flex: 1; } +.multi-file-commit-empty-state-container { + align-items: center; + justify-content: center; +} + .multi-file-commit-panel-header { display: flex; align-items: center; @@ -376,7 +480,7 @@ table.table tr td.multi-file-table-name { .multi-file-commit-panel-header-title { display: flex; flex: 1; - padding: $gl-btn-padding; + padding: 0 $gl-btn-padding; svg { margin-right: $gl-btn-padding; @@ -390,12 +494,34 @@ table.table tr td.multi-file-table-name { .multi-file-commit-list { flex: 1; overflow: auto; - padding: $gl-padding; + padding: $gl-padding 0; + min-height: 60px; } .multi-file-commit-list-item { display: flex; + padding: 0; align-items: center; + + .multi-file-discard-btn { + display: none; + margin-left: auto; + color: $gl-link-color; + padding: 0 2px; + + &:focus, + &:hover { + text-decoration: underline; + } + } + + &:hover { + background: $white-normal; + + .multi-file-discard-btn { + display: block; + } + } } .multi-file-addition { @@ -414,29 +540,58 @@ table.table tr td.multi-file-table-name { margin-left: auto; margin-right: auto; } + + .file-status-icon { + width: 10px; + height: 10px; + margin-left: 3px; + } } .multi-file-commit-list-path { + padding: $grid-size / 2; + padding-left: $gl-padding; + background: none; + border: 0; + text-align: left; + width: 100%; + min-width: 0; + + svg { + min-width: 16px; + vertical-align: middle; + display: inline-block; + } + + &:hover, + &:focus { + outline: 0; + } +} + +.multi-file-commit-list-file-path { @include str-truncated(100%); + + &:hover { + text-decoration: underline; + } + + &:active { + text-decoration: none; + } } .multi-file-commit-form { padding: $gl-padding; border-top: 1px solid $white-dark; -} - -.multi-file-commit-fieldset { - display: flex; - align-items: center; - padding-bottom: 12px; .btn { - flex: 1; + font-size: $gl-font-size; } } .multi-file-commit-message.form-control { - height: 80px; + height: 160px; resize: none; } @@ -468,7 +623,7 @@ table.table tr td.multi-file-table-name { top: 0; width: 100px; height: 1px; - background-color: rgba($red-500, .5); + background-color: rgba($red-500, 0.5); } } } @@ -487,7 +642,7 @@ table.table tr td.multi-file-table-name { justify-content: center; } -.repo-new-btn { +.ide-new-btn { .dropdown-toggle svg { margin-top: -2px; margin-bottom: 2px; @@ -505,36 +660,39 @@ table.table tr td.multi-file-table-name { } } -.ide.nav-only { - .flash-container { - margin-top: $header-height; - margin-bottom: 0; - } - - .alert-wrapper .flash-container .flash-alert:last-child, - .alert-wrapper .flash-container .flash-notice:last-child { - margin-bottom: 0; - } +.ide { + overflow: hidden; - .content { - margin-top: $header-height; - } + &.nav-only { + .flash-container { + margin-top: $header-height; + margin-bottom: 0; + } - .multi-file-commit-panel .multi-file-commit-panel-inner-scroll { - max-height: calc(100vh - #{$header-height + $context-header-height}); - } + .alert-wrapper .flash-container .flash-alert:last-child, + .alert-wrapper .flash-container .flash-notice:last-child { + margin-bottom: 0; + } - &.flash-shown { - .content { - margin-top: 0; + .content-wrapper { + margin-top: $header-height; + padding-bottom: 0; } - .ide-view { - height: calc(100vh - #{$header-height + $flash-height}); + &.flash-shown { + .content-wrapper { + margin-top: 0; + } + + .ide-view { + height: calc(100vh - #{$header-height + $flash-height}); + } } - .multi-file-commit-panel .multi-file-commit-panel-inner-scroll { - max-height: calc(100vh - #{$header-height + $flash-height + $context-header-height}); + .projects-sidebar { + .multi-file-commit-panel-inner-scroll { + flex: 1; + } } } } @@ -544,34 +702,28 @@ table.table tr td.multi-file-table-name { margin-top: #{$header-height + $performance-bar-height}; } - .content { + .content-wrapper { margin-top: #{$header-height + $performance-bar-height}; + padding-bottom: 0; } .ide-view { height: calc(100vh - #{$header-height + $performance-bar-height}); } - .multi-file-commit-panel .multi-file-commit-panel-inner-scroll { - max-height: calc(100vh - #{$header-height + $performance-bar-height + 60}); - } - &.flash-shown { - .content { + .content-wrapper { margin-top: 0; } .ide-view { - height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height}); - } - - .multi-file-commit-panel .multi-file-commit-panel-inner-scroll { - max-height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height + $context-header-height}); + height: calc( + 100vh - #{$header-height + $performance-bar-height + $flash-height} + ); } } } - .dragHandle { position: absolute; top: 0; @@ -587,3 +739,44 @@ table.table tr td.multi-file-table-name { left: 0; } } + +.ide-commit-radios { + label { + font-weight: normal; + } + + .help-block { + margin-top: 0; + line-height: 0; + } +} + +.ide-commit-new-branch { + margin-left: 25px; +} + +.ide-external-links { + p { + margin: 0; + } +} + +.ide-sidebar-link { + padding: $gl-padding-8 $gl-padding; + background: $indigo-700; + color: $white-light; + text-decoration: none; + display: flex; + align-items: center; + + &:focus, + &:hover { + color: $white-light; + text-decoration: underline; + background: $indigo-500; + } + + &:active { + background: $indigo-800; + } +} diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb new file mode 100644 index 00000000000..1ff25a45398 --- /dev/null +++ b/app/controllers/ide_controller.rb @@ -0,0 +1,6 @@ +class IdeController < ApplicationController + layout 'nav_only' + + def index + end +end diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb new file mode 100644 index 00000000000..f090ae71269 --- /dev/null +++ b/app/helpers/ide_helper.rb @@ -0,0 +1,14 @@ +module IdeHelper + def ide_edit_button(project = @project, ref = @ref, path = @path, options = {}) + return unless blob = readable_blob(options, path, project, ref) + + common_classes = "btn js-edit-ide #{options[:extra_class]}" + + edit_button_tag(blob, + common_classes, + _('Web IDE'), + ide_edit_path(project, ref, path, options), + project, + ref) + end +end diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml new file mode 100644 index 00000000000..e0e8fe548d0 --- /dev/null +++ b/app/views/ide/index.html.haml @@ -0,0 +1,12 @@ +- @body_class = 'ide' +- page_title 'IDE' + +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'ide', force_same_domain: true + +#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), + "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), + "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg') } } + .text-center + = icon('spinner spin 2x') + %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 06bce52e709..67613949b7d 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -76,4 +76,8 @@ = render 'projects/find_file_link' + = succeed " " do + = link_to ide_edit_path(@project, @id), class: 'btn btn-default' do + = _('Web IDE') + = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/config/routes.rb b/config/routes.rb index 8769f433c39..52726f94753 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,9 @@ Rails.application.routes.draw do # UserCallouts resources :user_callouts, only: [:create] + + get 'ide' => 'ide#index' + get 'ide/*vueroute' => 'ide#index', format: false end # Koding route diff --git a/config/webpack.config.js b/config/webpack.config.js index 3403c0c207d..f5fb7de6176 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -9,12 +9,14 @@ const StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin; const CopyWebpackPlugin = require('copy-webpack-plugin'); const CompressionPlugin = require('compression-webpack-plugin'); const NameAllModulesPlugin = require('name-all-modules-plugin'); -const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer') + .BundleAnalyzerPlugin; const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); const ROOT_PATH = path.resolve(__dirname, '..'); const IS_PRODUCTION = process.env.NODE_ENV === 'production'; -const IS_DEV_SERVER = process.argv.join(' ').indexOf('webpack-dev-server') !== -1; +const IS_DEV_SERVER = + process.argv.join(' ').indexOf('webpack-dev-server') !== -1; const DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; const DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; @@ -27,10 +29,10 @@ let watchAutoEntries = []; function generateEntries() { // generate automatic entry points const autoEntries = {}; - const pageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'app/assets/javascripts') }); - watchAutoEntries = [ - path.join(ROOT_PATH, 'app/assets/javascripts/pages/'), - ]; + const pageEntries = glob.sync('pages/**/index.js', { + cwd: path.join(ROOT_PATH, 'app/assets/javascripts'), + }); + watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')]; function generateAutoEntries(path, prefix = '.') { const chunkPath = path.replace(/\/index\.js$/, ''); @@ -38,15 +40,16 @@ function generateEntries() { autoEntries[chunkName] = `${prefix}/${path}`; } - pageEntries.forEach(( path ) => generateAutoEntries(path)); + pageEntries.forEach(path => generateAutoEntries(path)); autoEntriesCount = Object.keys(autoEntries).length; const manualEntries = { - common: './commons/index.js', - main: './main.js', - raven: './raven/index.js', - webpack_runtime: './webpack.js', + common: './commons/index.js', + main: './main.js', + raven: './raven/index.js', + webpack_runtime: './webpack.js', + ide: './ide/index.js', }; return Object.assign(manualEntries, autoEntries); @@ -60,8 +63,12 @@ const config = { output: { path: path.join(ROOT_PATH, 'public/assets/webpack'), publicPath: '/assets/webpack/', - filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js', - chunkFilename: IS_PRODUCTION ? '[name].[chunkhash].chunk.js' : '[name].chunk.js', + filename: IS_PRODUCTION + ? '[name].[chunkhash].bundle.js' + : '[name].bundle.js', + chunkFilename: IS_PRODUCTION + ? '[name].[chunkhash].chunk.js' + : '[name].chunk.js', }, module: { @@ -90,8 +97,8 @@ const config = { { loader: 'worker-loader', options: { - inline: true - } + inline: true, + }, }, { loader: 'babel-loader' }, ], @@ -102,7 +109,7 @@ const config = { loader: 'file-loader', options: { name: '[name].[hash].[ext]', - } + }, }, { test: /katex.css$/, @@ -112,8 +119,8 @@ const config = { { loader: 'css-loader', options: { - name: '[name].[hash].[ext]' - } + name: '[name].[hash].[ext]', + }, }, ], }, @@ -123,15 +130,18 @@ const config = { loader: 'file-loader', options: { name: '[name].[hash].[ext]', - } + }, }, { 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' }, + { + loader: 'imports-loader', + options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined', + }, ], - } + }, ], noParse: [/monaco-editor\/\w+\/vs\//], @@ -149,10 +159,10 @@ const config = { source: false, chunks: false, modules: false, - assets: true + assets: true, }); return JSON.stringify(stats, null, 2); - } + }, }), // prevent pikaday from including moment.js @@ -169,7 +179,7 @@ const config = { new NameAllModulesPlugin(), // assign deterministic chunk ids - new webpack.NamedChunksPlugin((chunk) => { + new webpack.NamedChunksPlugin(chunk => { if (chunk.name) { return chunk.name; } @@ -186,9 +196,12 @@ const config = { const pagesBase = path.join(ROOT_PATH, 'app/assets/javascripts/pages'); if (m.resource.indexOf(pagesBase) === 0) { - moduleNames.push(path.relative(pagesBase, m.resource) - .replace(/\/index\.[a-z]+$/, '') - .replace(/\//g, '__')); + moduleNames.push( + path + .relative(pagesBase, m.resource) + .replace(/\/index\.[a-z]+$/, '') + .replace(/\//g, '__'), + ); } else { moduleNames.push(path.relative(m.context, m.resource)); } @@ -196,7 +209,8 @@ const config = { chunk.forEachModule(collectModuleNames); - const hash = crypto.createHash('sha256') + const hash = crypto + .createHash('sha256') .update(moduleNames.join('_')) .digest('hex'); @@ -214,10 +228,17 @@ const config = { // copy pre-compiled vendor libraries verbatim new CopyWebpackPlugin([ { - from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`), + 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) && !/typescript/i.test(path)) { + if ( + /\.js$/.test(path) && + !/worker/i.test(path) && + !/typescript/i.test(path) + ) { return ( '(function(){\n' + 'var define = this.define, require = this.require;\n' + @@ -227,23 +248,23 @@ const config = { ); } return content; - } - } + }, + }, ]), ], resolve: { extensions: ['.js'], alias: { - '~': path.join(ROOT_PATH, 'app/assets/javascripts'), - 'emojis': path.join(ROOT_PATH, 'fixtures/emojis'), - 'empty_states': path.join(ROOT_PATH, 'app/views/shared/empty_states'), - 'icons': path.join(ROOT_PATH, 'app/views/shared/icons'), - 'images': path.join(ROOT_PATH, 'app/assets/images'), - 'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'), - 'vue$': 'vue/dist/vue.esm.js', - 'spec': path.join(ROOT_PATH, 'spec/javascripts'), - } + '~': path.join(ROOT_PATH, 'app/assets/javascripts'), + emojis: path.join(ROOT_PATH, 'fixtures/emojis'), + empty_states: path.join(ROOT_PATH, 'app/views/shared/empty_states'), + icons: path.join(ROOT_PATH, 'app/views/shared/icons'), + images: path.join(ROOT_PATH, 'app/assets/images'), + vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'), + vue$: 'vue/dist/vue.esm.js', + spec: path.join(ROOT_PATH, 'spec/javascripts'), + }, }, // sqljs requires fs @@ -258,14 +279,14 @@ if (IS_PRODUCTION) { new webpack.NoEmitOnErrorsPlugin(), new webpack.LoaderOptionsPlugin({ minimize: true, - debug: false + debug: false, }), new webpack.optimize.UglifyJsPlugin({ - sourceMap: true + sourceMap: true, }), new webpack.DefinePlugin({ - 'process.env': { NODE_ENV: JSON.stringify('production') } - }) + 'process.env': { NODE_ENV: JSON.stringify('production') }, + }), ); // compression can require a lot of compute time and is disabled in CI @@ -283,7 +304,7 @@ if (IS_DEV_SERVER) { headers: { 'Access-Control-Allow-Origin': '*' }, stats: 'errors-only', hot: DEV_SERVER_LIVERELOAD, - inline: DEV_SERVER_LIVERELOAD + inline: DEV_SERVER_LIVERELOAD, }; config.plugins.push( // watch node_modules for changes if we encounter a missing module compile error @@ -299,12 +320,14 @@ if (IS_DEV_SERVER) { ]; // report our auto-generated bundle count - console.log(`${autoEntriesCount} entries from '/pages' automatically added to webpack output.`); + console.log( + `${autoEntriesCount} entries from '/pages' automatically added to webpack output.`, + ); callback(); - }) + }); }, - } + }, ); if (DEV_SERVER_LIVERELOAD) { config.plugins.push(new webpack.HotModuleReplacementPlugin()); @@ -319,7 +342,7 @@ if (WEBPACK_REPORT) { openAnalyzer: false, reportFilename: path.join(ROOT_PATH, 'webpack-report/index.html'), statsFilename: path.join(ROOT_PATH, 'webpack-report/stats.json'), - }) + }), ); } diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb new file mode 100644 index 00000000000..d96c7e655ba --- /dev/null +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +feature 'Multi-file editor new directory', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + visit project_tree_path(project, :master) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'creates directory in current directory' do + find('.add-to-tree').click + + click_link('New directory') + + page.within('.modal') do + find('.form-control').set('folder name') + + click_button('Create directory') + end + + find('.add-to-tree').click + + click_link('New file') + + page.within('.modal-dialog') do + find('.form-control').set('file name') + + click_button('Create file') + end + + wait_for_requests + + fill_in('commit-message', with: 'commit message ide') + + click_button('Commit') + + expect(page).to have_content('folder name') + end +end diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb new file mode 100644 index 00000000000..a4cbd5cf766 --- /dev/null +++ b/spec/features/projects/tree/create_file_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +feature 'Multi-file editor new file', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + visit project_path(project) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'creates file in current directory' do + find('.add-to-tree').click + + click_link('New file') + + page.within('.modal') do + find('.form-control').set('file name') + + click_button('Create file') + end + + wait_for_requests + + fill_in('commit-message', with: 'commit message ide') + + click_button('Commit') + + expect(page).to have_content('file name') + end +end diff --git a/spec/features/projects/tree/upload_file_spec.rb b/spec/features/projects/tree/upload_file_spec.rb new file mode 100644 index 00000000000..8e53ae15700 --- /dev/null +++ b/spec/features/projects/tree/upload_file_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +feature 'Multi-file editor upload file', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:txt_file) { File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt') } + let(:img_file) { File.join(Rails.root, 'spec', 'fixtures', 'dk.png') } + + before do + project.add_master(user) + sign_in(user) + + visit project_tree_path(project, :master) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'uploads text file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', txt_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.multi-file-tab', text: 'doc_sample.txt') + expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline)) + end + + it 'uploads image file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', img_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.multi-file-tab', text: 'dk.png') + expect(page).not_to have_selector('.monaco-editor') + end +end diff --git a/spec/javascripts/ide/components/changed_file_icon_spec.js b/spec/javascripts/ide/components/changed_file_icon_spec.js new file mode 100644 index 00000000000..8f796b2f7f5 --- /dev/null +++ b/spec/javascripts/ide/components/changed_file_icon_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import changedFileIcon from 'ee/ide/components/changed_file_icon.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('IDE changed file icon', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(changedFileIcon); + + vm = createComponent(component, { + file: { + tempFile: false, + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('changedIcon', () => { + it('equals file-modified when not a temp file', () => { + expect(vm.changedIcon).toBe('file-modified'); + }); + + it('equals file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIcon).toBe('file-addition'); + }); + }); + + describe('changedIconClass', () => { + it('includes multi-file-modified when not a temp file', () => { + expect(vm.changedIconClass).toContain('multi-file-modified'); + }); + + it('includes multi-file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIconClass).toContain('multi-file-addition'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/actions_spec.js b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js new file mode 100644 index 00000000000..47a9007a8d0 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import commitActions from 'ee/ide/components/commit_sidebar/actions.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +describe('IDE commit sidebar actions', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(commitActions); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.currentBranchId = 'master'; + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 groups', () => { + expect(vm.$el.querySelectorAll('input[type="radio"]').length).toBe(3); + }); + + it('renders current branch text', () => { + expect(vm.$el.textContent).toContain('Commit to master branch'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js new file mode 100644 index 00000000000..f6789bc861f --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import listCollapsed from 'ee/ide/components/commit_sidebar/list_collapsed.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list collapsed', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(listCollapsed); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.changedFiles.push(file('file1'), file('file2')); + vm.$store.state.changedFiles[0].tempFile = true; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders added & modified files count', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js new file mode 100644 index 00000000000..543299950ea --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -0,0 +1,83 @@ +import Vue from 'vue'; +import listItem from 'ee/ide/components/commit_sidebar/list_item.vue'; +import router from 'ee/ide/ide_router'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list item', () => { + let vm; + let f; + + beforeEach(() => { + const Component = Vue.extend(listItem); + + f = file('test-file'); + + vm = mountComponent(Component, { + file: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders file path', () => { + expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path); + }); + + it('calls discardFileChanges when clicking discard button', () => { + spyOn(vm, 'discardFileChanges'); + + vm.$el.querySelector('.multi-file-discard-btn').click(); + + expect(vm.discardFileChanges).toHaveBeenCalled(); + }); + + it('opens a closed file in the editor when clicking the file path', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.openFileInEditor).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalled(); + }); + + it('calls updateViewer with diff when clicking file', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.updateViewer).toHaveBeenCalledWith('diff'); + }); + + describe('computed', () => { + describe('iconName', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconName).toBe('file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconName).toBe('file-addition'); + }); + }); + + describe('iconClass', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconClass).toContain('multi-file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconClass).toContain('multi-file-addition'); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js new file mode 100644 index 00000000000..f02d055e38c --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import commitSidebarList from 'ee/ide/components/commit_sidebar/list.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(commitSidebarList); + + vm = createComponentWithStore(Component, store, { + title: 'Staged', + fileList: [], + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('with a list of files', () => { + beforeEach((done) => { + const f = file('file name'); + f.changed = true; + vm.fileList.push(f); + + Vue.nextTick(done); + }); + + it('renders list', () => { + expect(vm.$el.querySelectorAll('li').length).toBe(1); + }); + }); + + describe('collapsed', () => { + beforeEach((done) => { + vm.$store.state.rightPanelCollapsed = true; + + Vue.nextTick(done); + }); + + it('hides list', () => { + expect(vm.$el.querySelector('.list-unstyled')).toBeNull(); + expect(vm.$el.querySelector('.help-block')).toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js new file mode 100644 index 00000000000..1058cc28de2 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js @@ -0,0 +1,130 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import radioGroup from 'ee/ide/components/commit_sidebar/radio_group.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +describe('IDE commit sidebar radio group', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '2'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('uses label if present', () => { + expect(vm.$el.textContent).toContain('test'); + }); + + it('uses slot if label is not present', (done) => { + vm.$destroy(); + + vm = new Vue({ + components: { + radioGroup, + }, + store, + template: ` + <radio-group + value="1" + > + Testing slot + </radio-group> + `, + }); + + vm.$mount(); + + Vue.nextTick(() => { + expect(vm.$el.textContent).toContain('Testing slot'); + + done(); + }); + }); + + it('updates store when changing radio button', (done) => { + vm.$el.querySelector('input').dispatchEvent(new Event('change')); + + Vue.nextTick(() => { + expect(store.state.commit.commitAction).toBe('1'); + + done(); + }); + }); + + it('renders helpText tooltip', (done) => { + vm.helpText = 'help text'; + + Vue.nextTick(() => { + const help = vm.$el.querySelector('.help-block'); + + expect(help).not.toBeNull(); + expect(help.getAttribute('data-original-title')).toBe('help text'); + + done(); + }); + }); + + describe('with input', () => { + beforeEach((done) => { + vm.$destroy(); + + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '1'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + showInput: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + it('renders input box when commitAction matches value', () => { + expect(vm.$el.querySelector('.form-control')).not.toBeNull(); + }); + + it('hides input when commitAction doesnt match value', (done) => { + store.state.commit.commitAction = '2'; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.form-control')).toBeNull(); + done(); + }); + }); + + it('updates branch name in store on input', (done) => { + const input = vm.$el.querySelector('.form-control'); + input.value = 'testing-123'; + input.dispatchEvent(new Event('input')); + + Vue.nextTick(() => { + expect(store.state.commit.newBranchName).toBe('testing-123'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js new file mode 100644 index 00000000000..9fa2e947db2 --- /dev/null +++ b/spec/javascripts/ide/components/ide_context_bar_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import ideContextBar from 'ee/ide/components/ide_context_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; + +describe('Multi-file editor right context bar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideContextBar); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('collapsed', () => { + beforeEach((done) => { + vm.$store.state.rightPanelCollapsed = true; + + Vue.nextTick(done); + }); + + it('adds collapsed class', () => { + expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js new file mode 100644 index 00000000000..b8da6747653 --- /dev/null +++ b/spec/javascripts/ide/components/ide_external_links_spec.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import ideExternalLinks from 'ee/ide/components/ide_external_links.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('ide external links component', () => { + let vm; + let fakeReferrer; + let Component; + + const fakeProjectUrl = '/project/'; + + beforeEach(() => { + Component = Vue.extend(ideExternalLinks); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('goBackUrl', () => { + it('renders the Go Back link with the referrer when present', () => { + fakeReferrer = '/example/README.md'; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm = createComponent(Component, { + projectUrl: fakeProjectUrl, + }).$mount(); + + expect(vm.goBackUrl).toEqual(fakeReferrer); + }); + + it('renders the Go Back link with the project url when referrer is not present', () => { + fakeReferrer = ''; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm = createComponent(Component, { + projectUrl: fakeProjectUrl, + }).$mount(); + + expect(vm.goBackUrl).toEqual(fakeProjectUrl); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js new file mode 100644 index 00000000000..e7188490f64 --- /dev/null +++ b/spec/javascripts/ide/components/ide_repo_tree_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import ideRepoTree from 'ee/ide/components/ide_repo_tree.vue'; +import createComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('IdeRepoTree', () => { + let vm; + let tree; + + beforeEach(() => { + const IdeRepoTree = Vue.extend(ideRepoTree); + + tree = { + tree: [file()], + loading: false, + }; + + vm = createComponent(IdeRepoTree, { + tree, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a sidebar', () => { + expect(vm.$el.querySelector('.loading-file')).toBeNull(); + expect(vm.$el.querySelector('.file')).not.toBeNull(); + }); + + it('renders 3 loading files if tree is loading', (done) => { + tree.loading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toEqual(3); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js new file mode 100644 index 00000000000..74afca280d1 --- /dev/null +++ b/spec/javascripts/ide/components/ide_side_bar_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import ideSidebar from 'ee/ide/components/ide_side_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; + +describe('IdeSidebar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideSidebar); + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a sidebar', () => { + expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull(); + }); + + it('renders loading icon component', (done) => { + vm.$store.state.loading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); + expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js new file mode 100644 index 00000000000..7f8dcd9049f --- /dev/null +++ b/spec/javascripts/ide/components/ide_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import ide from 'ee/ide/components/ide.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; + +describe('ide component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ide); + + vm = createComponentWithStore(Component, store, { + emptyStateSvgPath: 'svg', + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('does not render panel right when no files open', () => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + }); + + it('renders panel right when files are open', (done) => { + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [file()], + }; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/index_spec.js b/spec/javascripts/ide/components/new_dropdown/index_spec.js new file mode 100644 index 00000000000..cba27f94833 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/index_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import newDropdown from 'ee/ide/components/new_dropdown/index.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../../helpers'; + +describe('new dropdown component', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(newDropdown); + + vm = createComponentWithStore(component, store, { + branch: 'master', + path: '', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.path = ''; + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders new file, upload and new directory links', () => { + expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file'); + expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe( + 'Upload file', + ); + expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe( + 'New directory', + ); + }); + + describe('createNewItem', () => { + it('sets modalType to blob when new file is clicked', () => { + vm.$el.querySelectorAll('a')[0].click(); + + expect(vm.modalType).toBe('blob'); + }); + + it('sets modalType to tree when new directory is clicked', () => { + vm.$el.querySelectorAll('a')[2].click(); + + expect(vm.modalType).toBe('tree'); + }); + + it('opens modal when link is clicked', done => { + vm.$el.querySelectorAll('a')[0].click(); + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.modal')).not.toBeNull(); + + done(); + }); + }); + }); + + describe('hideModal', () => { + beforeAll(done => { + vm.openModal = true; + Vue.nextTick(done); + }); + + it('closes modal after toggling', done => { + vm.hideModal(); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.modal')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js new file mode 100644 index 00000000000..1a9c96c64da --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; +import modal from 'ee/ide/components/new_dropdown/modal.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new file modal component', () => { + const Component = Vue.extend(modal); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + ['tree', 'blob'].forEach((type) => { + describe(type, () => { + beforeEach(() => { + vm = createComponent(Component, { + type, + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + }); + + it(`sets modal title as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Create new ${title}`); + }); + + it(`sets button label as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Create ${title}`); + }); + + it(`sets form label as ${type}`, () => { + const title = type === 'tree' ? 'Directory' : 'File'; + + expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`); + }); + + describe('createEntryInStore', () => { + it('$emits create', () => { + spyOn(vm, '$emit'); + + vm.createEntryInStore(); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + branchId: 'master', + name: 'testing', + type, + }); + }); + }); + }); + }); + + it('focuses field on mount', () => { + document.body.innerHTML += '<div class="js-test"></div>'; + + vm = createComponent(Component, { + type: 'tree', + branchId: 'master', + path: '', + }, '.js-test'); + + expect(document.activeElement).toBe(vm.$refs.fieldName); + + vm.$el.remove(); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js new file mode 100644 index 00000000000..766e8b72360 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/upload_spec.js @@ -0,0 +1,87 @@ +import Vue from 'vue'; +import upload from 'ee/ide/components/new_dropdown/upload.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new dropdown upload', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(upload); + + vm = createComponent(Component, { + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + + spyOn(vm, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('readFile', () => { + beforeEach(() => { + spyOn(FileReader.prototype, 'readAsText'); + spyOn(FileReader.prototype, 'readAsDataURL'); + }); + + it('calls readAsText for text files', () => { + const file = { + type: 'text/html', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file); + }); + + it('calls readAsDataURL for non-text files', () => { + const file = { + type: 'images/png', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file); + }); + }); + + describe('createFile', () => { + const target = { + result: 'content', + }; + const binaryTarget = { + result: 'base64,base64content', + }; + const file = { + name: 'file', + }; + + it('creates new file', () => { + vm.createFile(target, file, true); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: target.result, + base64: false, + }); + }); + + it('splits content on base64 if binary', () => { + vm.createFile(binaryTarget, file, false); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: binaryTarget.result.split('base64,')[1], + base64: true, + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js new file mode 100644 index 00000000000..8090e3664e0 --- /dev/null +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -0,0 +1,154 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import repoCommitSection from 'ee/ide/components/repo_commit_section.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; +import { file, resetStore } from '../helpers'; + +describe('RepoCommitSection', () => { + let vm; + + function createComponent() { + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'commitsvg', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.currentBranchId = 'master'; + vm.$store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + + vm.$store.state.rightPanelCollapsed = false; + vm.$store.state.currentBranch = 'master'; + vm.$store.state.changedFiles = [file('file1'), file('file2')]; + vm.$store.state.changedFiles.forEach(f => Object.assign(f, { + changed: true, + content: 'testing', + })); + + return vm.$mount(); + } + + beforeEach((done) => { + vm = createComponent(); + + spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({ + headers: { + 'page-title': 'test', + }, + json: () => Promise.resolve({ + last_commit_path: 'last_commit_path', + parent_tree_url: 'parent_tree_url', + path: '/', + trees: [{ name: 'tree' }], + blobs: [{ name: 'blob' }], + submodules: [{ name: 'submodule' }], + }), + })); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + describe('empty Stage', () => { + it('renders no changes text', () => { + resetStore(vm.$store); + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'nochangessvg', + committedStateSvgPath: 'svg', + }).$mount(); + + expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes'); + expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('nochangessvg'); + }); + }); + + it('renders a commit section', () => { + const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')]; + const submitCommit = vm.$el.querySelector('form .btn'); + + expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); + expect(changedFileElements.length).toEqual(2); + + changedFileElements.forEach((changedFile, i) => { + expect(changedFile.textContent.trim()).toContain(vm.$store.state.changedFiles[i].path); + }); + + expect(submitCommit.disabled).toBeTruthy(); + expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); + }); + + it('updates commitMessage in store on input', (done) => { + const textarea = vm.$el.querySelector('textarea'); + + textarea.value = 'testing commit message'; + + textarea.dispatchEvent(new Event('input')); + + getSetTimeoutPromise() + .then(() => { + expect(vm.$store.state.commit.commitMessage).toBe('testing commit message'); + }) + .then(done) + .catch(done.fail); + }); + + describe('discard draft button', () => { + it('hidden when commitMessage is empty', () => { + expect(vm.$el.querySelector('.multi-file-commit-form .btn-default')).toBeNull(); + }); + + it('resets commitMessage when clicking discard button', (done) => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.multi-file-commit-form .btn-default').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when submitting', () => { + beforeEach(() => { + spyOn(vm, 'commitChanges'); + }); + + it('calls commitChanges', (done) => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.multi-file-commit-form .btn-success').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.commitChanges).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js new file mode 100644 index 00000000000..cda88623497 --- /dev/null +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -0,0 +1,137 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoEditor from 'ee/ide/components/repo_editor.vue'; +import monacoLoader from 'ee/ide/monaco_loader'; +import Editor from 'ee/ide/lib/editor'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; + +describe('RepoEditor', () => { + let vm; + + beforeEach((done) => { + const f = file(); + const RepoEditor = Vue.extend(repoEditor); + + vm = createComponentWithStore(RepoEditor, store, { + file: f, + }); + + f.active = true; + f.tempFile = true; + f.html = 'testing'; + vm.$store.state.openFiles.push(f); + vm.$store.state.entries[f.path] = f; + vm.monaco = true; + + vm.$mount(); + + monacoLoader(['vs/editor/editor.main'], () => { + setTimeout(done, 0); + }); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + + Editor.editorInstance.modelManager.dispose(); + }); + + it('renders an ide container', (done) => { + Vue.nextTick(() => { + expect(vm.shouldHideEditor).toBeFalsy(); + + done(); + }); + }); + + describe('when open file is binary and not raw', () => { + beforeEach((done) => { + vm.file.binary = true; + + vm.$nextTick(done); + }); + + it('does not render the IDE', () => { + expect(vm.shouldHideEditor).toBeTruthy(); + }); + + it('shows activeFile html', () => { + expect(vm.$el.textContent).toContain('testing'); + }); + }); + + describe('createEditorInstance', () => { + it('calls createInstance when viewer is editor', (done) => { + spyOn(vm.editor, 'createInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createInstance).toHaveBeenCalled(); + + done(); + }); + }); + + it('calls createDiffInstance when viewer is diff', (done) => { + vm.$store.state.viewer = 'diff'; + + spyOn(vm.editor, 'createDiffInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + + done(); + }); + }); + }); + + describe('setupEditor', () => { + it('creates new model', () => { + spyOn(vm.editor, 'createModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file); + expect(vm.model).not.toBeNull(); + }); + + it('attaches model to editor', () => { + spyOn(vm.editor, 'attachModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model); + }); + + it('adds callback methods', () => { + spyOn(vm.editor, 'onPositionChange').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.onPositionChange).toHaveBeenCalled(); + expect(vm.model.events.size).toBe(1); + }); + + it('updates state when model content changed', (done) => { + vm.model.setValue('testing 123'); + + setTimeout(() => { + expect(vm.file.content).toBe('testing 123'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_buttons_spec.js b/spec/javascripts/ide/components/repo_file_buttons_spec.js new file mode 100644 index 00000000000..13b452b1936 --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_buttons_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import repoFileButtons from 'ee/ide/components/repo_file_buttons.vue'; +import createVueComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFileButtons', () => { + const activeFile = file(); + let vm; + + function createComponent() { + const RepoFileButtons = Vue.extend(repoFileButtons); + + activeFile.rawPath = 'test'; + activeFile.blamePath = 'test'; + activeFile.commitsPath = 'test'; + + return createVueComponent(RepoFileButtons, { + file: activeFile, + }); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => { + vm = createComponent(); + + vm.$nextTick(() => { + const raw = vm.$el.querySelector('.raw'); + const blame = vm.$el.querySelector('.blame'); + const history = vm.$el.querySelector('.history'); + + expect(raw.href).toMatch(`/${activeFile.rawPath}`); + expect(raw.textContent.trim()).toEqual('Raw'); + expect(blame.href).toMatch(`/${activeFile.blamePath}`); + expect(blame.textContent.trim()).toEqual('Blame'); + expect(history.href).toMatch(`/${activeFile.commitsPath}`); + expect(history.textContent.trim()).toEqual('History'); + expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js new file mode 100644 index 00000000000..3bd871544ea --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoFile from 'ee/ide/components/repo_file.vue'; +import router from 'ee/ide/ide_router'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFile', () => { + let vm; + + function createComponent(propsData) { + const RepoFile = Vue.extend(repoFile); + + vm = createComponentWithStore(RepoFile, store, propsData); + + vm.$mount(); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders link, icon and name', () => { + createComponent({ + file: file('t4'), + level: 0, + }); + + const name = vm.$el.querySelector('.ide-file-name'); + + expect(name.href).toMatch(''); + expect(name.textContent.trim()).toEqual(vm.file.name); + }); + + it('fires clickFile when the link is clicked', done => { + spyOn(router, 'push'); + createComponent({ + file: file('t3'), + level: 0, + }); + + vm.$el.querySelector('.file-name').click(); + + setTimeout(() => { + expect(router.push).toHaveBeenCalledWith(`/project${vm.file.url}`); + + done(); + }); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + createComponent({ + file: f, + level: 0, + }); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect( + vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset + .originalTitle, + ).toContain('Locked by testuser'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_loading_file_spec.js b/spec/javascripts/ide/components/repo_loading_file_spec.js new file mode 100644 index 00000000000..dd267654289 --- /dev/null +++ b/spec/javascripts/ide/components/repo_loading_file_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoLoadingFile from 'ee/ide/components/repo_loading_file.vue'; +import { resetStore } from '../helpers'; + +describe('RepoLoadingFile', () => { + let vm; + + function createComponent() { + const RepoLoadingFile = Vue.extend(repoLoadingFile); + + return new RepoLoadingFile({ + store, + }).$mount(); + } + + function assertLines(lines) { + lines.forEach((line, n) => { + const index = n + 1; + expect(line.classList.contains(`skeleton-line-${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); + }); + } + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 columns of animated LoC', () => { + vm = createComponent(); + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(3); + assertColumns(columns); + }); + + it('renders 1 column of animated LoC if isMini', (done) => { + vm = createComponent(); + vm.$store.state.leftPanelCollapsed = true; + vm.$store.state.openFiles.push('test'); + + vm.$nextTick(() => { + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(1); + assertColumns(columns); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js new file mode 100644 index 00000000000..c3246cd1f1f --- /dev/null +++ b/spec/javascripts/ide/components/repo_tab_spec.js @@ -0,0 +1,163 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoTab from 'ee/ide/components/repo_tab.vue'; +import router from 'ee/ide/ide_router'; +import { file, resetStore } from '../helpers'; + +describe('RepoTab', () => { + let vm; + + function createComponent(propsData) { + const RepoTab = Vue.extend(repoTab); + + return new RepoTab({ + store, + propsData, + }).$mount(); + } + + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a close link and a name link', () => { + vm = createComponent({ + tab: file(), + }); + vm.$store.state.openFiles.push(vm.tab); + const close = vm.$el.querySelector('.multi-file-tab-close'); + const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`); + + expect(close.innerHTML).toContain('#close'); + expect(name.textContent.trim()).toEqual(vm.tab.name); + }); + + it('fires clickFile when the link is clicked', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'clickFile'); + + vm.$el.click(); + + expect(vm.clickFile).toHaveBeenCalledWith(vm.tab); + }); + + it('calls closeFile when clicking close button', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'closeFile'); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + expect(vm.closeFile).toHaveBeenCalledWith(vm.tab.path); + }); + + it('changes icon on hover', (done) => { + const tab = file(); + tab.changed = true; + vm = createComponent({ + tab, + }); + + vm.$el.dispatchEvent(new Event('mouseover')); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.multi-file-modified')).toBeNull(); + + vm.$el.dispatchEvent(new Event('mouseout')); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$el.querySelector('.multi-file-modified')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + vm = createComponent({ + tab: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain('Locked by testuser'); + }); + }); + + describe('methods', () => { + describe('closeTab', () => { + it('closes tab if file has changed', (done) => { + const tab = file(); + tab.changed = true; + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.changedFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + expect(vm.$store.state.changedFiles.length).toBe(1); + + done(); + }); + }); + + it('closes tab when clicking close btn', (done) => { + const tab = file('lose'); + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js new file mode 100644 index 00000000000..40834f230a8 --- /dev/null +++ b/spec/javascripts/ide/components/repo_tabs_spec.js @@ -0,0 +1,81 @@ +import Vue from 'vue'; +import repoTabs from 'ee/ide/components/repo_tabs.vue'; +import createComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoTabs', () => { + const openedFiles = [file('open1'), file('open2')]; + const RepoTabs = Vue.extend(repoTabs); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a list of tabs', done => { + vm = createComponent(RepoTabs, { + files: openedFiles, + viewer: 'editor', + hasChanges: false, + }); + openedFiles[0].active = true; + + vm.$nextTick(() => { + const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')]; + + expect(tabs.length).toEqual(2); + expect(tabs[0].classList.contains('active')).toEqual(true); + expect(tabs[1].classList.contains('active')).toEqual(false); + + done(); + }); + }); + + describe('updated', () => { + it('sets showShadow as true when scroll width is larger than width', done => { + const el = document.createElement('div'); + el.innerHTML = '<div id="test-app"></div>'; + document.body.appendChild(el); + + const style = document.createElement('style'); + style.innerText = ` + .multi-file-tabs { + width: 100px; + } + + .multi-file-tabs .list-unstyled { + display: flex; + overflow-x: auto; + } + `; + document.head.appendChild(style); + + vm = createComponent( + RepoTabs, + { + files: [], + viewer: 'editor', + hasChanges: false, + }, + '#test-app', + ); + + vm + .$nextTick() + .then(() => { + expect(vm.showShadow).toEqual(false); + + vm.files = openedFiles; + }) + .then(vm.$nextTick) + .then(() => { + expect(vm.showShadow).toEqual(true); + + style.remove(); + el.remove(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js new file mode 100644 index 00000000000..67f9eaff44a --- /dev/null +++ b/spec/javascripts/ide/helpers.js @@ -0,0 +1,21 @@ +import { decorateData } from 'ee/ide/stores/utils'; +import state from 'ee/ide/stores/state'; +import commitState from 'ee/ide/stores/modules/commit/state'; + +export const resetStore = (store) => { + const newState = { + ...state(), + commit: commitState(), + }; + store.replaceState(newState); +}; + +export const file = (name = 'name', id = name, type = '') => decorateData({ + id, + type, + icon: 'icon', + url: 'url', + name, + path: name, + lastCommit: {}, +}); diff --git a/spec/javascripts/ide/lib/common/disposable_spec.js b/spec/javascripts/ide/lib/common/disposable_spec.js new file mode 100644 index 00000000000..677986aff91 --- /dev/null +++ b/spec/javascripts/ide/lib/common/disposable_spec.js @@ -0,0 +1,44 @@ +import Disposable from 'ee/ide/lib/common/disposable'; + +describe('Multi-file editor library disposable class', () => { + let instance; + let disposableClass; + + beforeEach(() => { + instance = new Disposable(); + + disposableClass = { + dispose: jasmine.createSpy('dispose'), + }; + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('add', () => { + it('adds disposable classes', () => { + instance.add(disposableClass); + + expect(instance.disposers.size).toBe(1); + }); + }); + + describe('dispose', () => { + beforeEach(() => { + instance.add(disposableClass); + }); + + it('calls dispose on all cached disposers', () => { + instance.dispose(); + + expect(disposableClass.dispose).toHaveBeenCalled(); + }); + + it('clears cached disposers', () => { + instance.dispose(); + + expect(instance.disposers.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js new file mode 100644 index 00000000000..7a1fab0f74d --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_manager_spec.js @@ -0,0 +1,123 @@ +/* global monaco */ +import eventHub from 'ee/ide/eventhub'; +import monacoLoader from 'ee/ide/monaco_loader'; +import ModelManager from 'ee/ide/lib/common/model_manager'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model manager', () => { + let instance; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + instance = new ModelManager(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('addModel', () => { + it('caches model', () => { + instance.addModel(file()); + + expect(instance.models.size).toBe(1); + }); + + it('caches model by file path', () => { + instance.addModel(file('path-name')); + + expect(instance.models.keys().next().value).toBe('path-name'); + }); + + it('adds model into disposable', () => { + spyOn(instance.disposable, 'add').and.callThrough(); + + instance.addModel(file()); + + expect(instance.disposable.add).toHaveBeenCalled(); + }); + + it('returns cached model', () => { + spyOn(instance.models, 'get').and.callThrough(); + + instance.addModel(file()); + instance.addModel(file()); + + expect(instance.models.get).toHaveBeenCalled(); + }); + + it('adds eventHub listener', () => { + const f = file(); + spyOn(eventHub, '$on').and.callThrough(); + + instance.addModel(f); + + expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything()); + }); + }); + + describe('hasCachedModel', () => { + it('returns false when no models exist', () => { + expect(instance.hasCachedModel('path')).toBeFalsy(); + }); + + it('returns true when model exists', () => { + instance.addModel(file('path-name')); + + expect(instance.hasCachedModel('path-name')).toBeTruthy(); + }); + }); + + describe('getModel', () => { + it('returns cached model', () => { + instance.addModel(file('path-name')); + + expect(instance.getModel('path-name')).not.toBeNull(); + }); + }); + + describe('removeCachedModel', () => { + let f; + + beforeEach(() => { + f = file(); + + instance.addModel(f); + }); + + it('clears cached model', () => { + instance.removeCachedModel(f); + + expect(instance.models.size).toBe(0); + }); + + it('removes eventHub listener', () => { + spyOn(eventHub, '$off').and.callThrough(); + + instance.removeCachedModel(f); + + expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything()); + }); + }); + + describe('dispose', () => { + it('clears cached models', () => { + instance.addModel(file()); + + instance.dispose(); + + expect(instance.models.size).toBe(0); + }); + + it('calls disposable dispose', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js new file mode 100644 index 00000000000..dd9e4946883 --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_spec.js @@ -0,0 +1,107 @@ +/* global monaco */ +import eventHub from 'ee/ide/eventhub'; +import monacoLoader from 'ee/ide/monaco_loader'; +import Model from 'ee/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model', () => { + let model; + + beforeEach((done) => { + spyOn(eventHub, '$on').and.callThrough(); + + monacoLoader(['vs/editor/editor.main'], () => { + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + }); + + it('creates original model & new model', () => { + expect(model.originalModel).not.toBeNull(); + expect(model.model).not.toBeNull(); + }); + + it('adds eventHub listener', () => { + expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything()); + }); + + describe('path', () => { + it('returns file path', () => { + expect(model.path).toBe('path'); + }); + }); + + describe('getModel', () => { + it('returns model', () => { + expect(model.getModel()).toBe(model.model); + }); + }); + + describe('getOriginalModel', () => { + it('returns original model', () => { + expect(model.getOriginalModel()).toBe(model.originalModel); + }); + }); + + describe('setValue', () => { + it('updates models value', () => { + model.setValue('testing 123'); + + expect(model.getModel().getValue()).toBe('testing 123'); + }); + }); + + describe('onChange', () => { + it('caches event by path', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + expect(model.events.keys().next().value).toBe('path'); + }); + + it('calls callback on change', (done) => { + const spy = jasmine.createSpy(); + model.onChange(spy); + + model.getModel().setValue('123'); + + setTimeout(() => { + expect(spy).toHaveBeenCalledWith(model, jasmine.anything()); + done(); + }); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(model.disposable, 'dispose').and.callThrough(); + + model.dispose(); + + expect(model.disposable.dispose).toHaveBeenCalled(); + }); + + it('clears events', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + + model.dispose(); + + expect(model.events.size).toBe(0); + }); + + it('removes eventHub listener', () => { + spyOn(eventHub, '$off').and.callThrough(); + + model.dispose(); + + expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything()); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js new file mode 100644 index 00000000000..63e4282d4df --- /dev/null +++ b/spec/javascripts/ide/lib/decorations/controller_spec.js @@ -0,0 +1,120 @@ +/* global monaco */ +import monacoLoader from 'ee/ide/monaco_loader'; +import editor from 'ee/ide/lib/editor'; +import DecorationsController from 'ee/ide/lib/decorations/controller'; +import Model from 'ee/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library decorations controller', () => { + let editorInstance; + let controller; + let model; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + controller = new DecorationsController(editorInstance); + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + editorInstance.dispose(); + controller.dispose(); + }); + + describe('getAllDecorationsForModel', () => { + it('returns empty array when no decorations exist for model', () => { + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations).toEqual([]); + }); + + it('returns decorations by model URL', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations[0]).toEqual({ decoration: 'decorationValue' }); + }); + }); + + describe('addDecorations', () => { + it('caches decorations in a new map', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorations.size).toBe(1); + }); + + it('does not create new cache model', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]); + + expect(controller.decorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorations.size).toBe(1); + expect(controller.decorations.keys().next().value).toBe('path'); + }); + + it('calls decorate method', () => { + spyOn(controller, 'decorate'); + + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorate).toHaveBeenCalled(); + }); + }); + + describe('decorate', () => { + it('sets decorations on editor instance', () => { + spyOn(controller.editor.instance, 'deltaDecorations'); + + controller.decorate(model); + + expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []); + }); + + it('caches decorations', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.keys().next().value).toBe('path'); + }); + }); + + describe('dispose', () => { + it('clears cached decorations', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + controller.dispose(); + + expect(controller.decorations.size).toBe(0); + }); + + it('clears cached editorDecorations', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + controller.dispose(); + + expect(controller.editorDecorations.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js new file mode 100644 index 00000000000..90216f8b07e --- /dev/null +++ b/spec/javascripts/ide/lib/diff/controller_spec.js @@ -0,0 +1,176 @@ +/* global monaco */ +import monacoLoader from 'ee/ide/monaco_loader'; +import editor from 'ee/ide/lib/editor'; +import ModelManager from 'ee/ide/lib/common/model_manager'; +import DecorationsController from 'ee/ide/lib/decorations/controller'; +import DirtyDiffController, { getDiffChangeType, getDecorator } from 'ee/ide/lib/diff/controller'; +import { computeDiff } from 'ee/ide/lib/diff/diff'; +import { file } from '../../helpers'; + +describe('Multi-file editor library dirty diff controller', () => { + let editorInstance; + let controller; + let modelManager; + let decorationsController; + let model; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + modelManager = new ModelManager(monaco); + decorationsController = new DecorationsController(editorInstance); + + model = modelManager.addModel(file('path')); + + controller = new DirtyDiffController(modelManager, decorationsController); + + done(); + }); + }); + + afterEach(() => { + controller.dispose(); + model.dispose(); + decorationsController.dispose(); + editorInstance.dispose(); + }); + + describe('getDiffChangeType', () => { + ['added', 'removed', 'modified'].forEach((type) => { + it(`returns ${type}`, () => { + const change = { + [type]: true, + }; + + expect(getDiffChangeType(change)).toBe(type); + }); + }); + }); + + describe('getDecorator', () => { + ['added', 'removed', 'modified'].forEach((type) => { + it(`returns with linesDecorationsClassName for ${type}`, () => { + const change = { + [type]: true, + }; + + expect( + getDecorator(change).options.linesDecorationsClassName, + ).toBe(`dirty-diff dirty-diff-${type}`); + }); + + it('returns with line numbers', () => { + const change = { + lineNumber: 1, + endLineNumber: 2, + [type]: true, + }; + + const range = getDecorator(change).range; + + expect(range.startLineNumber).toBe(1); + expect(range.endLineNumber).toBe(2); + expect(range.startColumn).toBe(1); + expect(range.endColumn).toBe(1); + }); + }); + }); + + describe('attachModel', () => { + it('adds change event callback', () => { + spyOn(model, 'onChange'); + + controller.attachModel(model); + + expect(model.onChange).toHaveBeenCalled(); + }); + + it('calls throttledComputeDiff on change', () => { + spyOn(controller, 'throttledComputeDiff'); + + controller.attachModel(model); + + model.getModel().setValue('123'); + + expect(controller.throttledComputeDiff).toHaveBeenCalled(); + }); + }); + + describe('computeDiff', () => { + it('posts to worker', () => { + spyOn(controller.dirtyDiffWorker, 'postMessage'); + + controller.computeDiff(model); + + expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({ + path: model.path, + originalContent: '', + newContent: '', + }); + }); + }); + + describe('reDecorate', () => { + it('calls decorations controller decorate', () => { + spyOn(controller.decorationsController, 'decorate'); + + controller.reDecorate(model); + + expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model); + }); + }); + + describe('decorate', () => { + it('adds decorations into decorations controller', () => { + spyOn(controller.decorationsController, 'addDecorations'); + + controller.decorate({ data: { changes: [], path: 'path' } }); + + expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything()); + }); + + it('adds decorations into editor', () => { + const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations'); + + controller.decorate({ data: { changes: computeDiff('123', '1234'), path: 'path' } }); + + expect(spy).toHaveBeenCalledWith([], [{ + range: new monaco.Range( + 1, 1, 1, 1, + ), + options: { + isWholeLine: true, + linesDecorationsClassName: 'dirty-diff dirty-diff-modified', + }, + }]); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(controller.disposable, 'dispose').and.callThrough(); + + controller.dispose(); + + expect(controller.disposable.dispose).toHaveBeenCalled(); + }); + + it('terminates worker', () => { + spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled(); + }); + + it('removes worker event listener', () => { + spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith('message', jasmine.anything()); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/diff_spec.js b/spec/javascripts/ide/lib/diff/diff_spec.js new file mode 100644 index 00000000000..3bdd0a77e40 --- /dev/null +++ b/spec/javascripts/ide/lib/diff/diff_spec.js @@ -0,0 +1,80 @@ +import { computeDiff } from 'ee/ide/lib/diff/diff'; + +describe('Multi-file editor library diff calculator', () => { + describe('computeDiff', () => { + it('returns empty array if no changes', () => { + const diff = computeDiff('123', '123'); + + expect(diff).toEqual([]); + }); + + describe('modified', () => { + it('', () => { + const diff = computeDiff('123', '1234')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(2); + }); + }); + + describe('added', () => { + it('', () => { + const diff = computeDiff('123', '123\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(3); + }); + }); + + describe('removed', () => { + it('', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeTruthy(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeTruthy(); + expect(diff.lineNumber).toBe(2); + }); + }); + + it('includes line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.lineNumber).toBe(1); + }); + + it('includes end line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.endLineNumber).toBe(1); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_options_spec.js b/spec/javascripts/ide/lib/editor_options_spec.js new file mode 100644 index 00000000000..b974a6befd3 --- /dev/null +++ b/spec/javascripts/ide/lib/editor_options_spec.js @@ -0,0 +1,11 @@ +import editorOptions from 'ee/ide/lib/editor_options'; + +describe('Multi-file editor library editor options', () => { + it('returns an array', () => { + expect(editorOptions).toEqual(jasmine.any(Array)); + }); + + it('contains readOnly option', () => { + expect(editorOptions[0].readOnly).toBeDefined(); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js new file mode 100644 index 00000000000..76869bbc7ce --- /dev/null +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -0,0 +1,197 @@ +/* global monaco */ +import monacoLoader from 'ee/ide/monaco_loader'; +import editor from 'ee/ide/lib/editor'; +import { file } from '../helpers'; + +describe('Multi-file editor library', () => { + let instance; + let el; + let holder; + + beforeEach(done => { + el = document.createElement('div'); + holder = document.createElement('div'); + el.appendChild(holder); + + document.body.appendChild(el); + + monacoLoader(['vs/editor/editor.main'], () => { + instance = editor.create(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + + el.remove(); + }); + + it('creates instance of editor', () => { + expect(editor.editorInstance).not.toBeNull(); + }); + + it('creates instance returns cached instance', () => { + expect(editor.create(monaco)).toEqual(instance); + }); + + describe('createInstance', () => { + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'create').and.callThrough(); + + instance.createInstance(holder); + + expect(instance.monaco.editor.create).toHaveBeenCalled(); + }); + + it('creates dirty diff controller', () => { + instance.createInstance(holder); + + expect(instance.dirtyDiffController).not.toBeNull(); + }); + + it('creates model manager', () => { + instance.createInstance(holder); + + expect(instance.modelManager).not.toBeNull(); + }); + }); + + describe('createDiffInstance', () => { + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'createDiffEditor').and.callThrough(); + + instance.createDiffInstance(holder); + + expect(instance.monaco.editor.createDiffEditor).toHaveBeenCalledWith( + holder, + { + model: null, + contextmenu: true, + minimap: { + enabled: false, + }, + readOnly: true, + scrollBeyondLastLine: false, + }, + ); + }); + }); + + describe('createModel', () => { + it('calls model manager addModel', () => { + spyOn(instance.modelManager, 'addModel'); + + instance.createModel('FILE'); + + expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE'); + }); + }); + + describe('attachModel', () => { + let model; + + beforeEach(() => { + instance.createInstance(document.createElement('div')); + + model = instance.createModel(file()); + }); + + it('sets the current model on the instance', () => { + instance.attachModel(model); + + expect(instance.currentModel).toBe(model); + }); + + it('attaches the model to the current instance', () => { + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel()); + }); + + it('sets original & modified when diff editor', () => { + spyOn(instance.instance, 'getEditorType').and.returnValue( + 'vs.editor.IDiffEditor', + ); + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + }); + + it('attaches the model to the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'attachModel'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith( + model, + ); + }); + + it('re-decorates with the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'reDecorate'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith( + model, + ); + }); + }); + + describe('clearEditor', () => { + it('resets the editor model', () => { + instance.createInstance(document.createElement('div')); + + spyOn(instance.instance, 'setModel'); + + instance.clearEditor(); + + expect(instance.instance.setModel).toHaveBeenCalledWith(null); + }); + }); + + describe('dispose', () => { + it('calls disposble dispose method', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + + it('resets instance', () => { + instance.createInstance(document.createElement('div')); + + expect(instance.instance).not.toBeNull(); + + instance.dispose(); + + expect(instance.instance).toBeNull(); + }); + + it('does not dispose modelManager', () => { + spyOn(instance.modelManager, 'dispose'); + + instance.dispose(); + + expect(instance.modelManager.dispose).not.toHaveBeenCalled(); + }); + + it('does not dispose decorationsController', () => { + spyOn(instance.decorationsController, 'dispose'); + + instance.dispose(); + + expect(instance.decorationsController.dispose).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/monaco_loader_spec.js b/spec/javascripts/ide/monaco_loader_spec.js new file mode 100644 index 00000000000..43bc256e718 --- /dev/null +++ b/spec/javascripts/ide/monaco_loader_spec.js @@ -0,0 +1,13 @@ +import monacoContext from 'monaco-editor/dev/vs/loader'; +import monacoLoader from 'ee/ide/monaco_loader'; + +describe('MonacoLoader', () => { + it('calls require.config and exports require', () => { + expect(monacoContext.require.getConfig()).toEqual(jasmine.objectContaining({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, + })); + expect(monacoLoader).toBe(monacoContext.require); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js new file mode 100644 index 00000000000..55563e29d7f --- /dev/null +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -0,0 +1,421 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import router from 'ee/ide/ide_router'; +import eventHub from 'ee/ide/eventhub'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store file actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('closeFile', () => { + let localFile; + + beforeEach(() => { + localFile = file('testFile'); + localFile.active = true; + localFile.opened = true; + localFile.parentTreeUrl = 'parentTreeUrl'; + + store.state.openFiles.push(localFile); + store.state.entries[localFile.path] = localFile; + }); + + it('closes open files', done => { + store + .dispatch('closeFile', localFile.path) + .then(() => { + expect(localFile.opened).toBeFalsy(); + expect(localFile.active).toBeFalsy(); + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes file even if file has changes', done => { + store.state.changedFiles.push(localFile); + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('closes file & opens next available file', done => { + const f = { + ...file('newOpenFile'), + url: '/newOpenFile', + }; + + store.state.openFiles.push(f); + store.state.entries[f.path] = f; + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(router.push).toHaveBeenCalledWith(`/project${f.url}`); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setFileActive', () => { + let localFile; + let scrollToTabSpy; + let oldScrollToTab; + + beforeEach(() => { + scrollToTabSpy = jasmine.createSpy('scrollToTab'); + oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line + store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line + + localFile = file('setThisActive'); + + store.state.entries[localFile.path] = localFile; + }); + + afterEach(() => { + store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line + }); + + it('calls scrollToTab', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file active', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns early if file is already active', done => { + localFile.active = true; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).not.toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets current active file to not active', done => { + const f = file('newActive'); + store.state.entries[f.path] = f; + localFile.active = true; + store.state.openFiles.push(localFile); + + store + .dispatch('setFileActive', f.path) + .then(() => { + expect(localFile.active).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('resets location.hash for line highlighting', done => { + location.hash = 'test'; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(location.hash).not.toBe('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getFileData', () => { + let localFile; + + beforeEach(() => { + spyOn(service, 'getFileData').and.returnValue( + Promise.resolve({ + headers: { + 'page-title': 'testing getFileData', + }, + json: () => + Promise.resolve({ + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + raw_path: 'raw_path', + binary: false, + html: '123', + render_error: '', + }), + }), + ); + + localFile = file(`newCreate-${Math.random()}`); + localFile.url = 'getFileDataURL'; + store.state.entries[localFile.path] = localFile; + }); + + it('calls the service', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file data', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.blamePath).toBe('blame_path'); + + done(); + }) + .catch(done.fail); + }); + + it('sets document title', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(document.title).toBe('testing getFileData'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file as active', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('adds the file to open files', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(localFile.name); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getRawFileData', () => { + let tmpFile; + + beforeEach(() => { + spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw')); + + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('calls getRawFileData service method', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); + + done(); + }) + .catch(done.fail); + }); + + it('updates file raw data', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(tmpFile.raw).toBe('raw'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('changeFileContent', () => { + let tmpFile; + + beforeEach(() => { + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('updates file content', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(tmpFile.content).toBe('content'); + + done(); + }) + .catch(done.fail); + }); + + it('adds file into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('adds file once into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content 123', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array if not changed', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: '', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardFileChanges', () => { + let tmpFile; + + beforeEach(() => { + spyOn(eventHub, '$on'); + + tmpFile = file(); + tmpFile.content = 'testing'; + + store.state.changedFiles.push(tmpFile); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('resets file content', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.content).not.toBe('testing'); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes temp file', done => { + tmpFile.tempFile = true; + tmpFile.opened = true; + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('does not re-open a closed temp file', done => { + tmpFile.tempFile = true; + + expect(tmpFile.opened).toBeFalsy(); + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js new file mode 100644 index 00000000000..dba4bc10f9d --- /dev/null +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -0,0 +1,145 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import router from 'ee/ide/ide_router'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store tree actions', () => { + let projectTree; + + const basicCallParameters = { + endpoint: 'rootEndpoint', + projectId: 'abcproject', + branch: 'master', + branchId: 'master', + }; + + beforeEach(() => { + spyOn(router, 'push'); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + afterEach(() => { + resetStore(store); + }); + + describe('getFiles', () => { + beforeEach(() => { + spyOn(service, 'getFiles').and.returnValue(Promise.resolve({ + json: () => Promise.resolve([ + 'file.txt', + 'folder/fileinfolder.js', + 'folder/subfolder/fileinsubfolder.js', + ]), + })); + }); + + it('calls service getFiles', (done) => { + store.dispatch('getFiles', basicCallParameters) + .then(() => { + expect(service.getFiles).toHaveBeenCalledWith('', 'master'); + + done(); + }).catch(done.fail); + }); + + it('adds data into tree', (done) => { + store.dispatch('getFiles', basicCallParameters) + .then(() => { + projectTree = store.state.trees['abcproject/master']; + expect(projectTree.tree.length).toBe(2); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); + expect(projectTree.tree[1].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); + + done(); + }).catch(done.fail); + }); + }); + + describe('toggleTreeOpen', () => { + let tree; + + beforeEach(() => { + tree = file('testing', '1', 'tree'); + store.state.entries[tree.path] = tree; + }); + + it('toggles the tree open', (done) => { + store.dispatch('toggleTreeOpen', tree.path).then(() => { + expect(tree.opened).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + }); + + describe('getLastCommitData', () => { + beforeEach(() => { + spyOn(service, 'getTreeLastCommit').and.returnValue(Promise.resolve({ + headers: { + 'more-logs-url': null, + }, + json: () => Promise.resolve([{ + type: 'tree', + file_name: 'testing', + commit: { + message: 'commit message', + authored_date: '123', + }, + }]), + })); + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + projectTree = store.state.trees['abcproject/mybranch']; + projectTree.tree.push(file('testing', '1', 'tree')); + projectTree.lastCommitPath = 'lastcommitpath'; + }); + + it('calls service with lastCommitPath', (done) => { + store.dispatch('getLastCommitData', projectTree) + .then(() => { + expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath'); + + done(); + }).catch(done.fail); + }); + + it('updates trees last commit data', (done) => { + store.dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).toBe('commit message'); + + done(); + }).catch(done.fail); + }); + + it('does not update entry if not found', (done) => { + projectTree.tree[0].name = 'a'; + + store.dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message'); + + done(); + }).catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js new file mode 100644 index 00000000000..0da1226c7aa --- /dev/null +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -0,0 +1,306 @@ +import * as urlUtils from '~/lib/utils/url_utility'; +import store from 'ee/ide/stores'; +import router from 'ee/ide/ide_router'; +import { resetStore, file } from '../helpers'; + +describe('Multi-file store actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('redirectToUrl', () => { + it('calls visitUrl', done => { + spyOn(urlUtils, 'visitUrl'); + + store + .dispatch('redirectToUrl', 'test') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setInitialData', () => { + it('commits initial data', done => { + store + .dispatch('setInitialData', { canCommit: true }) + .then(() => { + expect(store.state.canCommit).toBeTruthy(); + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardAllChanges', () => { + beforeEach(() => { + const f = file('discardAll'); + f.changed = true; + + store.state.openFiles.push(f); + store.state.changedFiles.push(f); + store.state.entries[f.path] = f; + }); + + it('discards changes in file', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.openFiles.changed).toBeFalsy(); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all files from changedFiles state', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + expect(store.state.openFiles.length).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('closeAllFiles', () => { + beforeEach(() => { + const f = file('closeAll'); + store.state.openFiles.push(f); + store.state.openFiles[0].opened = true; + store.state.entries[f.path] = f; + }); + + it('closes all open files', done => { + store + .dispatch('closeAllFiles') + .then(() => { + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('createTempEntry', () => { + beforeEach(() => { + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'mybranch'; + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + store.state.projects.abcproject = { + web_url: '', + }; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('tree', () => { + it('creates temp tree', done => { + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'test', + type: 'tree', + }) + .then(() => { + const entry = store.state.entries.test; + + expect(entry).not.toBeNull(); + expect(entry.type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('creates new folder inside another tree', done => { + const tree = { + type: 'tree', + name: 'testing', + path: 'testing', + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing/test', + type: 'tree', + }) + .then(() => { + expect(tree.tree[0].tempFile).toBeTruthy(); + expect(tree.tree[0].name).toBe('test'); + expect(tree.tree[0].type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('does not create new tree if already exists', done => { + const tree = { + type: 'tree', + path: 'testing', + tempFile: false, + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing', + type: 'tree', + }) + .then(() => { + expect(store.state.entries[tree.path].tempFile).toEqual(false); + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('blob', () => { + it('creates temp file', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.tempFile).toBeTruthy(); + expect(store.state.trees['abcproject/mybranch'].tree.length).toBe( + 1, + ); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to open files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to changed files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.changedFiles.length).toBe(1); + expect(store.state.changedFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('sets tmp file as active', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('creates flash message if file already exists', done => { + const f = file('test', '1', 'blob'); + store.state.trees['abcproject/mybranch'].tree = [f]; + store.state.entries[f.path] = f; + + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('popHistoryState', () => {}); + + describe('scrollToTab', () => { + it('focuses the current active element', done => { + document.body.innerHTML += + '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>'; + const el = document.querySelector('.repo-tab'); + spyOn(el, 'focus'); + + store + .dispatch('scrollToTab') + .then(() => { + setTimeout(() => { + expect(el.focus).toHaveBeenCalled(); + + document.getElementById('tabs').remove(); + + done(); + }); + }) + .catch(done.fail); + }); + }); + + describe('updateViewer', () => { + it('updates viewer state', done => { + store + .dispatch('updateViewer', 'diff') + .then(() => { + expect(store.state.viewer).toBe('diff'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js new file mode 100644 index 00000000000..2fb69339915 --- /dev/null +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -0,0 +1,55 @@ +import * as getters from 'ee/ide/stores/getters'; +import state from 'ee/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store getters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('activeFile', () => { + it('returns the current active file', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + localState.openFiles[1].active = true; + + expect(getters.activeFile(localState).name).toBe('active'); + }); + + it('returns undefined if no active files are found', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + + expect(getters.activeFile(localState)).toBeNull(); + }); + }); + + describe('modifiedFiles', () => { + it('returns a list of modified files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('changed')); + localState.changedFiles[0].changed = true; + + const modifiedFiles = getters.modifiedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('changed'); + }); + }); + + describe('addedFiles', () => { + it('returns a list of added files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('added')); + localState.changedFiles[0].changed = true; + localState.changedFiles[0].tempFile = true; + + const modifiedFiles = getters.addedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('added'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js new file mode 100644 index 00000000000..0aef29f77e3 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js @@ -0,0 +1,450 @@ +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import router from 'ee/ide/ide_router'; +import * as urlUtils from '~/lib/utils/url_utility'; +import eventHub from 'ee/ide/eventhub'; +import * as consts from 'ee/ide/stores/modules/commit/constants'; +import { resetStore, file } from 'spec/ide/helpers'; + +describe('IDE commit module actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('updateCommitMessage', () => { + it('updates store with new commit message', (done) => { + store.dispatch('commit/updateCommitMessage', 'testing') + .then(() => { + expect(store.state.commit.commitMessage).toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('discardDraft', () => { + it('resets commit message to blank', (done) => { + store.state.commit.commitMessage = 'testing'; + + store.dispatch('commit/discardDraft') + .then(() => { + expect(store.state.commit.commitMessage).not.toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateCommitAction', () => { + it('updates store with new commit action', (done) => { + store.dispatch('commit/updateCommitAction', '1') + .then(() => { + expect(store.state.commit.commitAction).toBe('1'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateBranchName', () => { + it('updates store with new branch name', (done) => { + store.dispatch('commit/updateBranchName', 'branch-name') + .then(() => { + expect(store.state.commit.newBranchName).toBe('branch-name'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('setLastCommitMessage', () => { + beforeEach(() => { + Object.assign(store.state, { + currentProjectId: 'abcproject', + projects: { + abcproject: { + web_url: 'http://testing', + }, + }, + }); + }); + + it('updates commit message with short_id', (done) => { + store.dispatch('commit/setLastCommitMessage', { short_id: '123' }) + .then(() => { + expect(store.state.lastCommitMsg).toContain( + 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a>', + ); + }) + .then(done) + .catch(done.fail); + }); + + it('updates commit message with stats', (done) => { + store.dispatch('commit/setLastCommitMessage', { + short_id: '123', + stats: { + additions: '1', + deletions: '2', + }, + }) + .then(() => { + expect(store.state.lastCommitMsg).toBe('Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('checkCommitStatus', () => { + beforeEach(() => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + it('calls service', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + data: { + commit: { id: '123' }, + }, + })); + + store.dispatch('commit/checkCommitStatus') + .then(() => { + expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master'); + + done(); + }) + .catch(done.fail); + }); + + it('returns true if current ref does not equal returned ID', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + data: { + commit: { id: '123' }, + }, + })); + + store.dispatch('commit/checkCommitStatus') + .then((val) => { + expect(val).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns false if current ref equals returned ID', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + data: { + commit: { id: '1' }, + }, + })); + + store.dispatch('commit/checkCommitStatus') + .then((val) => { + expect(val).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('updateFilesAfterCommit', () => { + const data = { + id: '123', + message: 'testing commit message', + committed_date: '123', + committer_name: 'root', + }; + const branch = 'master'; + let f; + + beforeEach(() => { + spyOn(eventHub, '$emit'); + + f = file('changedFile'); + Object.assign(f, { + active: true, + changed: true, + content: 'file content', + }); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'web_url', + branches: { + master: { + workingReference: '', + }, + }, + }; + store.state.changedFiles.push(f, { + ...file('changedFile2'), + changed: true, + }); + store.state.openFiles = store.state.changedFiles; + + store.state.changedFiles.forEach((changedFile) => { + store.state.entries[changedFile.path] = changedFile; + }); + }); + + it('updates stores working reference', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect( + store.state.projects.abcproject.branches.master.workingReference, + ).toBe(data.id); + }) + .then(done) + .catch(done.fail); + }); + + it('resets all files changed status', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + store.state.openFiles.forEach((entry) => { + expect(entry.changed).toBeFalsy(); + }); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all changed files', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('sets files commit data', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.lastCommit.message).toBe(data.message); + }) + .then(done) + .catch(done.fail); + }); + + it('updates raw content for changed file', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.raw).toBe(f.content); + }) + .then(done) + .catch(done.fail); + }); + + it('emits changed event for file', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.path}`, f.content); + }) + .then(done) + .catch(done.fail); + }); + + it('pushes route to new branch if commitAction is new branch', (done) => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(router.push).toHaveBeenCalledWith( + `/project/abcproject/blob/master/${f.path}`, + ); + }) + .then(done) + .catch(done.fail); + }); + + it('resets stores commit actions', (done) => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.commit.commitAction).not.toBe(consts.COMMIT_TO_NEW_BRANCH); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('commitChanges', () => { + beforeEach(() => { + spyOn(urlUtils, 'visitUrl'); + + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'webUrl', + branches: { + master: { + workingReference: '1', + }, + }, + }; + store.state.changedFiles.push(file('changed')); + store.state.changedFiles[0].active = true; + store.state.openFiles = store.state.changedFiles; + + store.state.openFiles.forEach((f) => { + store.state.entries[f.path] = f; + }); + + store.state.commit.commitAction = '2'; + store.state.commit.commitMessage = 'testing 123'; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('success', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue(Promise.resolve({ + data: { + id: '123456', + short_id: '123', + message: 'test message', + committed_date: 'date', + stats: { + additions: '1', + deletions: '2', + }, + }, + })); + }); + + it('calls service', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: jasmine.anything(), + commit_message: 'testing 123', + actions: [{ + action: 'update', + file_path: jasmine.anything(), + content: jasmine.anything(), + encoding: jasmine.anything(), + }], + start_branch: 'master', + }); + + done(); + }).catch(done.fail); + }); + + it('pushes router to new route', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(router.push).toHaveBeenCalledWith( + `/project/${store.state.currentProjectId}/blob/${store.getters['commit/newBranchName']}/changed`, + ); + + done(); + }).catch(done.fail); + }); + + it('sets last Commit Msg', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="webUrl/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); + + done(); + }).catch(done.fail); + }); + + it('adds commit data to changed files', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(store.state.openFiles[0].lastCommit.message).toBe('test message'); + + done(); + }).catch(done.fail); + }); + + it('redirects to new merge request page', (done) => { + spyOn(eventHub, '$on'); + + store.state.commit.commitAction = '3'; + + store.dispatch('commit/commitChanges') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith( + `webUrl/merge_requests/new?merge_request[source_branch]=${store.getters['commit/newBranchName']}&merge_request[target_branch]=master`, + ); + + done(); + }).catch(done.fail); + }); + }); + + describe('failed', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue(Promise.resolve({ + data: { + message: 'failed message', + }, + })); + }); + + it('shows failed message', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + const alert = document.querySelector('.flash-container'); + + expect(alert.textContent.trim()).toBe( + 'failed message', + ); + + done(); + }).catch(done.fail); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/getters_spec.js b/spec/javascripts/ide/stores/modules/commit/getters_spec.js new file mode 100644 index 00000000000..b1467bcf3c7 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/getters_spec.js @@ -0,0 +1,114 @@ +import commitState from 'ee/ide/stores/modules/commit/state'; +import * as consts from 'ee/ide/stores/modules/commit/constants'; +import * as getters from 'ee/ide/stores/modules/commit/getters'; + +describe('IDE commit module getters', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('discardDraftButtonDisabled', () => { + it('returns true when commitMessage is empty', () => { + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + + it('returns false when commitMessage is not empty & loading is false', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = false; + + expect(getters.discardDraftButtonDisabled(state)).toBeFalsy(); + }); + + it('returns true when commitMessage is not empty & loading is true', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = true; + + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + }); + + describe('commitButtonDisabled', () => { + const localGetters = { + discardDraftButtonDisabled: false, + }; + const rootState = { + changedFiles: ['a'], + }; + + it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeFalsy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => { + rootState.changedFiles.length = 0; + + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is true', () => { + localGetters.discardDraftButtonDisabled = true; + + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + localGetters.discardDraftButtonDisabled = false; + rootState.changedFiles.length = 0; + + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + }); + }); + + describe('newBranchName', () => { + it('includes username, currentBranchId, patch & random number', () => { + gon.current_username = 'username'; + + const branch = getters.newBranchName(state, null, { currentBranchId: 'testing' }); + + expect(branch).toMatch(/username-testing-patch-\d{5}$/); + }); + }); + + describe('branchName', () => { + const rootState = { + currentBranchId: 'master', + }; + const localGetters = { + newBranchName: 'newBranchName', + }; + + beforeEach(() => { + Object.assign(state, { + newBranchName: 'state-newBranchName', + }); + }); + + it('defualts to currentBranchId', () => { + expect(getters.branchName(state, null, rootState)).toBe('master'); + }); + + ['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach((type) => { + describe(type, () => { + beforeEach(() => { + Object.assign(state, { + commitAction: consts[type], + }); + }); + + it('uses newBranchName when not empty', () => { + expect(getters.branchName(state, localGetters, rootState)).toBe('state-newBranchName'); + }); + + it('uses getters newBranchName when state newBranchName is empty', () => { + Object.assign(state, { + newBranchName: '', + }); + + expect(getters.branchName(state, localGetters, rootState)).toBe('newBranchName'); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/mutations_spec.js b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js new file mode 100644 index 00000000000..fa43e3d9d02 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js @@ -0,0 +1,42 @@ +import commitState from 'ee/ide/stores/modules/commit/state'; +import mutations from 'ee/ide/stores/modules/commit/mutations'; + +describe('IDE commit module mutations', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('UPDATE_COMMIT_MESSAGE', () => { + it('updates commitMessage', () => { + mutations.UPDATE_COMMIT_MESSAGE(state, 'testing'); + + expect(state.commitMessage).toBe('testing'); + }); + }); + + describe('UPDATE_COMMIT_ACTION', () => { + it('updates commitAction', () => { + mutations.UPDATE_COMMIT_ACTION(state, 'testing'); + + expect(state.commitAction).toBe('testing'); + }); + }); + + describe('UPDATE_NEW_BRANCH_NAME', () => { + it('updates newBranchName', () => { + mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing'); + + expect(state.newBranchName).toBe('testing'); + }); + }); + + describe('UPDATE_LOADING', () => { + it('updates submitCommitLoading', () => { + mutations.UPDATE_LOADING(state, true); + + expect(state.submitCommitLoading).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js new file mode 100644 index 00000000000..1601769144a --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/branch_spec.js @@ -0,0 +1,18 @@ +import mutations from 'ee/ide/stores/mutations/branch'; +import state from 'ee/ide/stores/state'; + +describe('Multi-file store branch mutations', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('SET_CURRENT_BRANCH', () => { + it('sets currentBranch', () => { + mutations.SET_CURRENT_BRANCH(localState, 'master'); + + expect(localState.currentBranchId).toBe('master'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js new file mode 100644 index 00000000000..944639c3336 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -0,0 +1,157 @@ +import mutations from 'ee/ide/stores/mutations/file'; +import state from 'ee/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store file mutations', () => { + let localState; + let localFile; + + beforeEach(() => { + localState = state(); + localFile = file(); + + localState.entries[localFile.path] = localFile; + }); + + describe('SET_FILE_ACTIVE', () => { + it('sets the file active', () => { + mutations.SET_FILE_ACTIVE(localState, { + path: localFile.path, + active: true, + }); + + expect(localFile.active).toBeTruthy(); + }); + }); + + describe('TOGGLE_FILE_OPEN', () => { + beforeEach(() => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + }); + + it('adds into opened files', () => { + expect(localFile.opened).toBeTruthy(); + expect(localState.openFiles.length).toBe(1); + }); + + it('removes from opened files', () => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + + expect(localFile.opened).toBeFalsy(); + expect(localState.openFiles.length).toBe(0); + }); + }); + + describe('SET_FILE_DATA', () => { + it('sets extra file data', () => { + mutations.SET_FILE_DATA(localState, { + data: { + blame_path: 'blame', + commits_path: 'commits', + permalink: 'permalink', + raw_path: 'raw', + binary: true, + render_error: 'render_error', + }, + file: localFile, + }); + + expect(localFile.blamePath).toBe('blame'); + expect(localFile.commitsPath).toBe('commits'); + expect(localFile.permalink).toBe('permalink'); + expect(localFile.rawPath).toBe('raw'); + expect(localFile.binary).toBeTruthy(); + expect(localFile.renderError).toBe('render_error'); + }); + }); + + describe('SET_FILE_RAW_DATA', () => { + it('sets raw data', () => { + mutations.SET_FILE_RAW_DATA(localState, { + file: localFile, + raw: 'testing', + }); + + expect(localFile.raw).toBe('testing'); + }); + }); + + describe('UPDATE_FILE_CONTENT', () => { + beforeEach(() => { + localFile.raw = 'test'; + }); + + it('sets content', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'test', + }); + + expect(localFile.content).toBe('test'); + }); + + it('sets changed if content does not match raw', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'testing', + }); + + expect(localFile.content).toBe('testing'); + expect(localFile.changed).toBeTruthy(); + }); + + it('sets changed if file is a temp file', () => { + localFile.tempFile = true; + + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: '', + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); + + describe('DISCARD_FILE_CHANGES', () => { + beforeEach(() => { + localFile.content = 'test'; + localFile.changed = true; + }); + + it('resets content and changed', () => { + mutations.DISCARD_FILE_CHANGES(localState, localFile.path); + + expect(localFile.content).toBe(''); + expect(localFile.changed).toBeFalsy(); + }); + }); + + describe('ADD_FILE_TO_CHANGED', () => { + it('adds file into changed files array', () => { + mutations.ADD_FILE_TO_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(1); + }); + }); + + describe('REMOVE_FILE_FROM_CHANGED', () => { + it('removes files from changed files array', () => { + localState.changedFiles.push(localFile); + + mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(0); + }); + }); + + describe('TOGGLE_FILE_CHANGED', () => { + it('updates file changed status', () => { + mutations.TOGGLE_FILE_CHANGED(localState, { + file: localFile, + changed: true, + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/tree_spec.js b/spec/javascripts/ide/stores/mutations/tree_spec.js new file mode 100644 index 00000000000..e321eff8749 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/tree_spec.js @@ -0,0 +1,67 @@ +import mutations from 'ee/ide/stores/mutations/tree'; +import state from 'ee/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store tree mutations', () => { + let localState; + let localTree; + + beforeEach(() => { + localState = state(); + localTree = file(); + + localState.entries[localTree.path] = localTree; + }); + + describe('TOGGLE_TREE_OPEN', () => { + it('toggles tree open', () => { + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeTruthy(); + + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeFalsy(); + }); + }); + + describe('SET_DIRECTORY_DATA', () => { + const data = [{ + name: 'tree', + }, + { + name: 'submodule', + }, + { + name: 'blob', + }]; + + it('adds directory data', () => { + localState.trees['project/master'] = { + tree: [], + }; + + mutations.SET_DIRECTORY_DATA(localState, { + data, + treePath: 'project/master', + }); + + const tree = localState.trees['project/master']; + + expect(tree.tree.length).toBe(3); + expect(tree.tree[0].name).toBe('tree'); + expect(tree.tree[1].name).toBe('submodule'); + expect(tree.tree[2].name).toBe('blob'); + }); + }); + + describe('REMOVE_ALL_CHANGES_FILES', () => { + it('removes all files from changedFiles state', () => { + localState.changedFiles.push(file('REMOVE_ALL_CHANGES_FILES')); + + mutations.REMOVE_ALL_CHANGES_FILES(localState); + + expect(localState.changedFiles.length).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js new file mode 100644 index 00000000000..e0d214010d5 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -0,0 +1,79 @@ +import mutations from 'ee/ide/stores/mutations'; +import state from 'ee/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store mutations', () => { + let localState; + let entry; + + beforeEach(() => { + localState = state(); + entry = file(); + + localState.entries[entry.path] = entry; + }); + + describe('SET_INITIAL_DATA', () => { + it('sets all initial data', () => { + mutations.SET_INITIAL_DATA(localState, { + test: 'test', + }); + + expect(localState.test).toBe('test'); + }); + }); + + describe('TOGGLE_LOADING', () => { + it('toggles loading of entry', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeFalsy(); + }); + + it('toggles loading of entry and sets specific value', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry, forceValue: true }); + + expect(entry.loading).toBeTruthy(); + }); + }); + + describe('SET_LEFT_PANEL_COLLAPSED', () => { + it('sets left panel collapsed', () => { + mutations.SET_LEFT_PANEL_COLLAPSED(localState, true); + + expect(localState.leftPanelCollapsed).toBeTruthy(); + + mutations.SET_LEFT_PANEL_COLLAPSED(localState, false); + + expect(localState.leftPanelCollapsed).toBeFalsy(); + }); + }); + + describe('SET_RIGHT_PANEL_COLLAPSED', () => { + it('sets right panel collapsed', () => { + mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true); + + expect(localState.rightPanelCollapsed).toBeTruthy(); + + mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false); + + expect(localState.rightPanelCollapsed).toBeFalsy(); + }); + }); + + describe('UPDATE_VIEWER', () => { + it('sets viewer state', () => { + mutations.UPDATE_VIEWER(localState, 'diff'); + + expect(localState.viewer).toBe('diff'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js new file mode 100644 index 00000000000..a473d3a4294 --- /dev/null +++ b/spec/javascripts/ide/stores/utils_spec.js @@ -0,0 +1,60 @@ +import * as utils from 'ee/ide/stores/utils'; + +describe('Multi-file store utils', () => { + describe('setPageTitle', () => { + it('sets the document page title', () => { + utils.setPageTitle('test'); + + expect(document.title).toBe('test'); + }); + }); + + describe('findIndexOfFile', () => { + let localState; + + beforeEach(() => { + localState = [{ + path: '1', + }, { + path: '2', + }]; + }); + + it('finds in the index of an entry by path', () => { + const index = utils.findIndexOfFile(localState, { + path: '2', + }); + + expect(index).toBe(1); + }); + }); + + describe('findEntry', () => { + let localState; + + beforeEach(() => { + localState = { + tree: [{ + type: 'tree', + name: 'test', + }, { + type: 'blob', + name: 'file', + }], + }; + }); + + it('returns an entry found by name', () => { + const foundEntry = utils.findEntry(localState.tree, 'tree', 'test'); + + expect(foundEntry.type).toBe('tree'); + expect(foundEntry.name).toBe('test'); + }); + + it('returns undefined when no entry found', () => { + const foundEntry = utils.findEntry(localState.tree, 'blob', 'test'); + + expect(foundEntry).toBeUndefined(); + }); + }); +}); |