diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
commit | 8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch) | |
tree | a77e7fe7a93de11213032ed4ab1f33a3db51b738 /app/assets/javascripts/ide | |
parent | 00b35af3db1abfe813a778f643dad221aad51fca (diff) | |
download | gitlab-ce-8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781.tar.gz |
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'app/assets/javascripts/ide')
99 files changed, 2262 insertions, 477 deletions
diff --git a/app/assets/javascripts/ide/commit_icon.js b/app/assets/javascripts/ide/commit_icon.js new file mode 100644 index 00000000000..4984b5bb91d --- /dev/null +++ b/app/assets/javascripts/ide/commit_icon.js @@ -0,0 +1,11 @@ +import { commitItemIconMap } from './constants'; + +export default file => { + if (file.deleted) { + return commitItemIconMap.deleted; + } else if (file.tempFile && !file.prevPath) { + return commitItemIconMap.addition; + } + + return commitItemIconMap.modified; +}; diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 186d4b6d7d2..a65af55fcac 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import { leftSidebarViews } from '../constants'; @@ -13,7 +13,6 @@ export default { tooltip, }, computed: { - ...mapGetters(['hasChanges']), ...mapState(['currentActivityView']), }, methods: { @@ -23,6 +22,8 @@ export default { this.updateActivityBarView(view); + // TODO: We must use JQuery here to interact with the Bootstrap tooltip API + // https://gitlab.com/gitlab-org/gitlab/-/issues/217577 $(e.currentTarget).tooltip('hide'); }, }, @@ -67,7 +68,7 @@ export default { <icon name="file-modified" /> </button> </li> - <li v-show="hasChanges"> + <li> <button v-tooltip :class="{ diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue index 58a0631ee0d..e7f4cd796b5 100644 --- a/app/assets/javascripts/ide/components/branches/item.vue +++ b/app/assets/javascripts/ide/components/branches/item.vue @@ -2,7 +2,6 @@ /* eslint-disable @gitlab/vue-require-i18n-strings */ import Icon from '~/vue_shared/components/icon.vue'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; -import router from '../../ide_router'; export default { components: { @@ -26,7 +25,7 @@ export default { }, computed: { branchHref() { - return router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href; + return this.$router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href; }, }, }; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 24499fb9f6d..59a32dd477e 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -29,7 +29,7 @@ export default { }, }, methods: { - ...mapActions(['stageChange', 'unstageChange', 'discardFileChanges']), + ...mapActions(['unstageChange', 'discardFileChanges']), showDiscardModal() { this.$refs.discardModal.show(); }, @@ -56,7 +56,7 @@ export default { v-if="canDiscard" ref="discardButton" type="button" - class="btn btn-remove btn-inverted append-right-8" + class="btn btn-remove btn-inverted gl-mr-3" @click="showDiscardModal" > {{ __('Discard changes') }} diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue index a23bae8e4c7..a13ca0cd138 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue @@ -9,10 +9,7 @@ export default { </script> <template> - <div - v-if="!lastCommitMsg" - class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state" - > + <div v-if="!lastCommitMsg" class="multi-file-commit-panel-section ide-commit-empty-state"> <div class="ide-commit-empty-state-container"> <div class="svg-content svg-80"><img :src="noChangesStateSvgPath" /></div> <div class="append-right-default prepend-left-default"> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 4cbd33e6ed6..3bba4fbc906 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -26,7 +26,7 @@ export default { computed: { ...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), - ...mapGetters(['hasChanges']), + ...mapGetters(['someUncommittedChanges']), ...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']), overviewText() { return n__('%d changed file', '%d changed files', this.stagedFiles.length); @@ -40,20 +40,9 @@ export default { }, }, watch: { - currentActivityView() { - if (this.lastCommitMsg) { - this.isCompact = false; - } else { - this.isCompact = !( - this.currentViewIsCommitView && window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT - ); - } - }, - - lastCommitMsg() { - this.isCompact = - this.currentActivityView !== leftSidebarViews.commit.name && this.lastCommitMsg === ''; - }, + currentActivityView: 'handleCompactState', + someUncommittedChanges: 'handleCompactState', + lastCommitMsg: 'handleCompactState', }, methods: { ...mapActions(['updateActivityBarView']), @@ -71,19 +60,24 @@ export default { forceCreateNewBranch() { return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit()); }, - toggleIsCompact() { - if (this.currentViewIsCommitView) { - this.isCompact = !this.isCompact; + handleCompactState() { + if (this.lastCommitMsg) { + this.isCompact = false; } else { - this.updateActivityBarView(leftSidebarViews.commit.name) - .then(() => { - this.isCompact = false; - }) - .catch(e => { - throw e; - }); + this.isCompact = + !this.someUncommittedChanges || + !this.currentViewIsCommitView || + window.innerHeight < MAX_WINDOW_HEIGHT_COMPACT; } }, + toggleIsCompact() { + this.isCompact = !this.isCompact; + }, + beginCommit() { + return this.updateActivityBarView(leftSidebarViews.commit.name).then(() => { + this.isCompact = false; + }); + }, beforeEnterTransition() { const elHeight = this.isCompact ? this.$refs.formEl && this.$refs.formEl.offsetHeight @@ -126,16 +120,17 @@ export default { > <div v-if="isCompact" ref="compactEl" class="commit-form-compact"> <button - :disabled="!hasChanges" + :disabled="!someUncommittedChanges" type="button" class="btn btn-primary btn-sm btn-block qa-begin-commit-button" - @click="toggleIsCompact" + data-testid="begin-commit-button" + @click="beginCommit" > {{ __('Commit…') }} </button> <p class="text-center bold">{{ overviewText }}</p> </div> - <form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commit"> + <form v-else ref="formEl" @submit.prevent.stop="commit"> <transition name="fade"> <success-message v-show="lastCommitMsg" /> </transition> <commit-message-field :text="commitMessage" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index e6a1a1ba73c..5cff1079eb0 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -55,7 +55,7 @@ export default { }, }, methods: { - ...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']), + ...mapActions(['unstageAllChanges', 'discardAllChanges']), openDiscardModal() { this.$refs.discardAllModal.show(); }, @@ -74,7 +74,7 @@ export default { <div class="ide-commit-list-container"> <header class="multi-file-commit-panel-header d-flex mb-0"> <div class="d-flex align-items-center flex-fill"> - <icon v-once :name="iconName" :size="18" class="append-right-8" /> + <icon v-once :name="iconName" :size="18" class="gl-mr-3" /> <strong> {{ titleText }} </strong> <div class="d-flex ml-auto"> <button @@ -98,7 +98,7 @@ export default { </div> </div> </header> - <ul v-if="filesLength" class="multi-file-commit-list list-unstyled append-bottom-0"> + <ul v-if="filesLength" class="multi-file-commit-list list-unstyled gl-mb-0"> <li v-for="file in fileList" :key="file.key"> <list-item :file="file" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index e70e251c117..c65169f5d31 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -4,7 +4,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { viewerTypes } from '../../constants'; -import { getCommitIconMap } from '../../utils'; +import getCommitIconMap from '../../commit_icon'; export default { components: { @@ -87,7 +87,7 @@ export default { @click="openFileInEditor" > <span class="multi-file-commit-list-file-path d-flex align-items-center"> - <file-icon :file-name="file.name" class="append-right-8" /> + <file-icon :file-name="file.name" class="gl-mr-3" /> <template v-if="file.prevName && file.prevName !== file.name"> {{ file.prevName }} → </template> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 32822a75772..51509cd5fe6 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -89,7 +89,7 @@ export default { :type="file.type" :path="file.path" :is-open="dropdownOpen" - class="prepend-left-8" + class="gl-ml-3" v-on="$listeners" /> </div> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 36c8b18e205..e9f84eb8648 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,5 +1,4 @@ <script> -import Vue from 'vue'; import { mapActions, mapGetters, mapState } from 'vuex'; import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; @@ -27,20 +26,13 @@ export default { CommitEditorHeader, GlDeprecatedButton, GlLoadingIcon, + RightPane, }, mixins: [glFeatureFlagsMixin()], - props: { - rightPaneComponent: { - type: Vue.Component, - required: false, - default: () => RightPane, - }, - }, computed: { ...mapState([ 'openFiles', 'viewer', - 'currentMergeRequestId', 'fileFindVisible', 'emptyStateSvgPath', 'currentProjectId', @@ -49,7 +41,6 @@ export default { ]), ...mapGetters([ 'activeFile', - 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive', 'allBlobs', @@ -108,14 +99,7 @@ export default { <div class="multi-file-edit-pane"> <template v-if="activeFile"> <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" /> - <repo-tabs - v-else - :active-file="activeFile" - :files="openFiles" - :viewer="viewer" - :has-changes="hasChanges" - :merge-request-id="currentMergeRequestId" - /> + <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" /> <repo-editor :file="activeFile" class="multi-file-edit-pane-content" /> </template> <template v-else> @@ -141,6 +125,7 @@ export default { variant="success" :title="__('New file')" :aria-label="__('New file')" + data-qa-selector="first_file_button" @click="createNewFile()" > {{ __('New file') }} @@ -160,7 +145,7 @@ export default { </div> </template> </div> - <component :is="rightPaneComponent" v-if="currentProjectId" /> + <right-pane v-if="currentProjectId" /> </div> <ide-status-bar /> <new-modal ref="newModal" /> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 7cb31df85ce..1eb89b41495 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -9,7 +9,7 @@ import CommitForm from './commit_sidebar/form.vue'; import IdeReview from './ide_review.vue'; import SuccessMessage from './commit_sidebar/success_message.vue'; import IdeProjectHeader from './ide_project_header.vue'; -import { leftSidebarViews } from '../constants'; +import { leftSidebarViews, SIDEBAR_INIT_WIDTH } from '../constants'; export default { components: { @@ -33,11 +33,16 @@ export default { ); }, }, + SIDEBAR_INIT_WIDTH, }; </script> <template> - <resizable-panel :initial-width="340" side="left" class="flex-column"> + <resizable-panel + :initial-width="$options.SIDEBAR_INIT_WIDTH" + side="left" + class="multi-file-commit-panel flex-column" + > <template v-if="loading"> <div class="multi-file-commit-panel-inner"> <div v-for="n in 3" :key="n" class="multi-file-loading-container"> diff --git a/app/assets/javascripts/ide/components/ide_sidebar_nav.vue b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue new file mode 100644 index 00000000000..966c36d6e71 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue @@ -0,0 +1,83 @@ +<script> +import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { otherSide } from '../utils'; +import { SIDE_RIGHT } from '../constants'; + +export default { + directives: { + tooltip: GlTooltipDirective, + }, + components: { + GlIcon, + }, + props: { + tabs: { + type: Array, + required: true, + }, + side: { + type: String, + required: true, + }, + currentView: { + type: String, + required: false, + default: '', + }, + isOpen: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + otherSide() { + return otherSide(this.side); + }, + }, + methods: { + isActiveTab(tab) { + return this.isOpen && tab.views.some(view => view.name === this.currentView); + }, + buttonClasses(tab) { + return [ + { + 'is-right': this.side === SIDE_RIGHT, + active: this.isActiveTab(tab), + }, + ...(tab.buttonClasses || []), + ]; + }, + clickTab(e, tab) { + e.currentTarget.blur(); + this.$root.$emit('bv::hide::tooltip'); + + if (this.isActiveTab(tab)) { + this.$emit('close'); + } else { + this.$emit('open', tab.views[0]); + } + }, + }, +}; +</script> +<template> + <nav class="ide-activity-bar"> + <ul class="list-unstyled"> + <li v-for="tab of tabs" :key="tab.title"> + <button + v-tooltip="{ container: 'body', placement: otherSide }" + :title="tab.title" + :aria-label="tab.title" + :class="buttonClasses(tab)" + :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`" + class="ide-sidebar-link" + type="button" + @click="clickTab($event, tab)" + > + <gl-icon :size="16" :name="tab.icon" /> + </button> + </li> + </ul> + </nav> +</template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 5585343f367..ddc126c3d77 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { mapActions, mapState, mapGetters } from 'vuex'; -import IdeStatusList from 'ee_else_ce/ide/components/ide_status_list.vue'; +import IdeStatusList from './ide_status_list.vue'; import IdeStatusMr from './ide_status_mr.vue'; import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index 364e3f081a1..92d25709bd5 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -1,9 +1,17 @@ <script> import { mapGetters } from 'vuex'; +import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue'; +import { getFileEOL } from '../utils'; export default { + components: { + TerminalSyncStatusSafe, + }, computed: { ...mapGetters(['activeFile']), + activeFileEOL() { + return getFileEOL(this.activeFile.content); + }, }, }; </script> @@ -12,12 +20,12 @@ export default { <div class="ide-status-list d-flex"> <template v-if="activeFile"> <div class="ide-status-file">{{ activeFile.name }}</div> - <div class="ide-status-file">{{ activeFile.eol }}</div> + <div class="ide-status-file">{{ activeFileEOL }}</div> <div v-if="!activeFile.binary" class="ide-status-file"> {{ activeFile.editorRow }}:{{ activeFile.editorColumn }} </div> <div class="ide-status-file">{{ activeFile.fileLanguage }}</div> </template> - <slot></slot> + <terminal-sync-status-safe /> </div> </template> diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue index 9c0c97bc5ae..f1ba102fffe 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/description.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -24,7 +24,7 @@ export default { <template> <div class="d-flex align-items-center"> <ci-icon :status="job.status" :borderless="true" :size="24" class="d-flex" /> - <span class="prepend-left-8"> + <span class="gl-ml-3"> {{ job.name }} <a :href="job.path" target="_blank" class="ide-external-link position-relative"> {{ jobId }} <icon :size="12" name="external-link" /> diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index ba8407382f4..169a948c2da 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -71,11 +71,11 @@ export default { v-tooltip="showTooltip" :title="showTooltip ? stage.name : null" data-container="body" - class="prepend-left-8 text-truncate" + class="gl-ml-3 text-truncate" > {{ stage.name }} </strong> - <div v-if="!stage.isLoading || stage.jobs.length" class="append-right-8 prepend-left-4"> + <div v-if="!stage.isLoading || stage.jobs.length" class="gl-mr-3 gl-ml-2"> <span class="badge badge-pill"> {{ jobsCount }} </span> </div> <icon :name="collapseIcon" class="ide-stage-collapse-icon" /> diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue index 60889c893cf..3f060392686 100644 --- a/app/assets/javascripts/ide/components/merge_requests/item.vue +++ b/app/assets/javascripts/ide/components/merge_requests/item.vue @@ -1,6 +1,5 @@ <script> import Icon from '../../../vue_shared/components/icon.vue'; -import router from '../../ide_router'; export default { components: { @@ -33,7 +32,7 @@ export default { mergeRequestHref() { const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`; - return router.resolve(path).href; + return this.$router.resolve(path).href; }, }, }; diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue index cf8a1abbde4..4fab57b6f3e 100644 --- a/app/assets/javascripts/ide/components/mr_file_icon.vue +++ b/app/assets/javascripts/ide/components/mr_file_icon.vue @@ -18,6 +18,6 @@ export default { :title="__('Part of merge request changes')" :size="12" name="git-merge" - class="append-right-8" + class="gl-mr-3" /> </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 4766a2fe6ae..586d6867ab4 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -4,7 +4,7 @@ import flash from '~/flash'; import { __, sprintf, s__ } from '~/locale'; import { GlModal } from '@gitlab/ui'; import { modalTypes } from '../../constants'; -import { trimPathComponents } from '../../utils'; +import { trimPathComponents, getPathParent } from '../../utils'; export default { components: { @@ -85,8 +85,10 @@ export default { } }, createFromTemplate(template) { + const parent = getPathParent(this.entryName); + const name = parent ? `${parent}/${template.name}` : template.name; this.createTempEntry({ - name: template.name, + name, type: this.modalType, }); @@ -133,7 +135,7 @@ export default { <gl-modal ref="modal" modal-id="ide-new-entry" - modal-class="qa-new-file-modal" + data-qa-selector="new_file_modal" :title="modalTitle" :ok-title="buttonLabel" ok-variant="success" @@ -148,7 +150,8 @@ export default { ref="fieldName" v-model.trim="entryName" type="text" - class="form-control qa-full-file-path" + class="form-control" + data-qa-selector="file_name_field" :placeholder="placeholder" /> <ul diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 7261e0590c8..b2141c13d9f 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -35,7 +35,6 @@ export default { name: `${this.path ? `${this.path}/` : ''}${name}`, type: 'blob', content, - base64: !isText, binary: !isText, rawPath: !isText ? target.result : '', }); diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue index 91e80be7d18..4e8e1e3a470 100644 --- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue +++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue @@ -2,8 +2,7 @@ import { mapActions, mapState } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; -import ResizablePanel from '../resizable_panel.vue'; -import { GlSkeletonLoading } from '@gitlab/ui'; +import IdeSidebarNav from '../ide_sidebar_nav.vue'; export default { name: 'CollapsibleSidebar', @@ -12,8 +11,7 @@ export default { }, components: { Icon, - ResizablePanel, - GlSkeletonLoading, + IdeSidebarNav, }, props: { extensionTabs: { @@ -25,13 +23,8 @@ export default { type: String, required: true, }, - width: { - type: Number, - required: true, - }, }, computed: { - ...mapState(['loading']), ...mapState({ isOpen(state) { return state[this.namespace].isOpen; @@ -39,9 +32,6 @@ export default { currentView(state) { return state[this.namespace].currentView; }, - isActiveView(state, getters) { - return getters[`${this.namespace}/isActiveView`]; - }, isAliveView(_state, getters) { return getters[`${this.namespace}/isAliveView`]; }, @@ -59,9 +49,6 @@ export default { aliveTabViews() { return this.tabViews.filter(view => this.isAliveView(view.name)); }, - otherSide() { - return this.side === 'right' ? 'left' : 'right'; - }, }, methods: { ...mapActions({ @@ -72,25 +59,6 @@ export default { return dispatch(`${this.namespace}/open`, view); }, }), - clickTab(e, tab) { - e.target.blur(); - - if (this.isActiveTab(tab)) { - this.toggleOpen(); - } else { - this.open(tab.views[0]); - } - }, - isActiveTab(tab) { - return tab.views.some(view => this.isActiveView(view.name)); - }, - buttonClasses(tab) { - return [ - this.side === 'right' ? 'is-right' : '', - this.isActiveTab(tab) && this.isOpen ? 'active' : '', - ...(tab.buttonClasses || []), - ]; - }, }, }; </script> @@ -101,49 +69,27 @@ export default { :data-qa-selector="`ide_${side}_sidebar`" class="multi-file-commit-panel ide-sidebar" > - <resizable-panel + <div v-show="isOpen" - :initial-width="width" - :min-size="width" :class="`ide-${side}-sidebar-${currentView}`" - :side="side" class="multi-file-commit-panel-inner" > - <div class="h-100 d-flex flex-column align-items-stretch"> - <slot v-if="isOpen" name="header"></slot> - <div - v-for="tabView in aliveTabViews" - v-show="isActiveView(tabView.name)" - :key="tabView.name" - class="flex-fill gl-overflow-hidden js-tab-view" - > - <component :is="tabView.component" /> - </div> - <slot name="footer"></slot> + <div + v-for="tabView in aliveTabViews" + v-show="tabView.name === currentView" + :key="tabView.name" + class="flex-fill gl-overflow-hidden js-tab-view gl-h-full" + > + <component :is="tabView.component" /> </div> - </resizable-panel> - <nav class="ide-activity-bar"> - <ul class="list-unstyled"> - <li> - <slot name="header-icon"></slot> - </li> - <li v-for="tab of tabs" :key="tab.title"> - <button - v-tooltip - :title="tab.title" - :aria-label="tab.title" - :class="buttonClasses(tab)" - data-container="body" - :data-placement="otherSide" - :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`" - class="ide-sidebar-link" - type="button" - @click="clickTab($event, tab)" - > - <icon :size="16" :name="tab.icon" /> - </button> - </li> - </ul> - </nav> + </div> + <ide-sidebar-nav + :tabs="tabs" + :side="side" + :current-view="currentView" + :is-open="isOpen" + @open="open" + @close="toggleOpen" + /> </div> </template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 4a9de9e0c03..46ef08a45a9 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -2,26 +2,27 @@ import { mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; import CollapsibleSidebar from './collapsible_sidebar.vue'; -import { rightSidebarViews } from '../../constants'; +import ResizablePanel from '../resizable_panel.vue'; +import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants'; import PipelinesList from '../pipelines/list.vue'; import JobsDetail from '../jobs/detail.vue'; import Clientside from '../preview/clientside.vue'; +import TerminalView from '../terminal/view.vue'; + +// Need to add the width of the nav buttons since the resizable container contains those as well +const WIDTH = SIDEBAR_INIT_WIDTH + SIDEBAR_NAV_WIDTH; export default { name: 'RightPane', components: { CollapsibleSidebar, - }, - props: { - extensionTabs: { - type: Array, - required: false, - default: () => [], - }, + ResizablePanel, }, computed: { + ...mapState('terminal', { isTerminalVisible: 'isVisible' }), ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']), ...mapGetters(['packageJson']), + ...mapState('rightPane', ['isOpen']), showLivePreview() { return this.packageJson && this.clientsidePreviewEnabled; }, @@ -42,13 +43,27 @@ export default { views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }], icon: 'live-preview', }, - ...this.extensionTabs, + { + show: this.isTerminalVisible, + title: __('Terminal'), + views: [{ component: TerminalView, ...rightSidebarViews.terminal }], + icon: 'terminal', + }, ]; }, }, + WIDTH, }; </script> <template> - <collapsible-sidebar :extension-tabs="rightExtensionTabs" side="right" :width="350" /> + <resizable-panel + class="gl-display-flex gl-overflow-hidden" + side="right" + :initial-width="$options.WIDTH" + :min-size="$options.WIDTH" + :resizable="isOpen" + > + <collapsible-sidebar class="gl-w-full" :extension-tabs="rightExtensionTabs" side="right" /> + </resizable-panel> </template> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index cf6d01b6351..6958a5d2526 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -63,7 +63,7 @@ export default { <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> <ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" /> - <span class="prepend-left-8"> + <span class="gl-ml-3"> <strong> {{ __('Pipeline') }} </strong> <a :href="latestPipeline.path" @@ -82,9 +82,9 @@ export default { class="mb-auto mt-auto" /> <div v-else-if="latestPipeline.yamlError" class="bs-callout bs-callout-danger"> - <p class="append-bottom-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p> - <p class="append-bottom-0 break-word">{{ latestPipeline.yamlError }}</p> - <p class="append-bottom-0" v-html="ciLintText"></p> + <p class="gl-mb-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p> + <p class="gl-mb-0 break-word">{{ latestPipeline.yamlError }}</p> + <p class="gl-mb-0" v-html="ciLintText"></p> </div> <tabs v-else class="ide-pipeline-list"> <tab :active="!pipelineFailed"> diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue index ff23485f0f0..0de9dfd8827 100644 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -119,7 +119,7 @@ export default { > <icon :size="18" name="retry" class="m-auto" /> </button> - <div class="position-relative w-100 prepend-left-4"> + <div class="position-relative w-100 gl-ml-2"> <input :value="path || '/'" type="text" diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 530fba49df2..5eed57bb6c5 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -3,7 +3,7 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; -import { leftSidebarViews, stageKeys } from '../constants'; +import { stageKeys } from '../constants'; export default { components: { @@ -14,39 +14,37 @@ export default { tooltip, }, computed: { - ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg', 'unusedSeal']), + ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), - ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']), + ...mapGetters(['lastOpenedFile', 'someUncommittedChanges', 'activeFile']), ...mapGetters('commit', ['discardDraftButtonDisabled']), showStageUnstageArea() { - return Boolean(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal); + return Boolean(this.someUncommittedChanges || this.lastCommitMsg); }, activeFileKey() { return this.activeFile ? this.activeFile.key : null; }, }, - watch: { - hasChanges() { - if (!this.hasChanges) { - this.updateActivityBarView(leftSidebarViews.edit.name); - } - }, - }, mounted() { - if (this.lastOpenedFile && this.lastOpenedFile.type !== 'tree') { - this.openPendingTab({ - file: this.lastOpenedFile, - keyPrefix: this.lastOpenedFile.staged ? stageKeys.staged : stageKeys.unstaged, + const file = + this.lastOpenedFile && this.lastOpenedFile.type !== 'tree' + ? this.lastOpenedFile + : this.activeFile; + + if (!file) return; + + this.openPendingTab({ + file, + keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged, + }) + .then(changeViewer => { + if (changeViewer) { + this.updateViewer('diff'); + } }) - .then(changeViewer => { - if (changeViewer) { - this.updateViewer('diff'); - } - }) - .catch(e => { - throw e; - }); - } + .catch(e => { + throw e; + }); }, methods: { ...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']), @@ -67,6 +65,6 @@ export default { icon-name="unstaged" /> </template> - <empty-state v-if="unusedSeal" /> + <empty-state v-else /> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index c72a8b2b0d0..a7646083428 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -14,6 +14,9 @@ import Editor from '../lib/editor'; import FileTemplatesBar from './file_templates/bar.vue'; import { __ } from '~/locale'; import { extractMarkdownImagesFromEntries } from '../stores/utils'; +import { getPathParent, readFileAsDataURL } from '../utils'; +import { getRulesWithTraversal } from '../lib/editorconfig/parser'; +import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; export default { components: { @@ -31,6 +34,7 @@ export default { return { content: '', images: {}, + rules: {}, }; }, computed: { @@ -50,7 +54,6 @@ export default { 'getStagedFile', 'isEditModeActive', 'isCommitModeActive', - 'isReviewModeActive', 'currentBranch', ]), ...mapGetters('fileTemplates', ['showFileTemplatesBar']), @@ -82,10 +85,6 @@ export default { active: this.isPreviewViewMode, }; }, - fileType() { - const info = viewerInformationForPath(this.file.path); - return (info && info.id) || ''; - }, showEditor() { return !this.shouldHideEditor && this.isEditorViewMode; }, @@ -98,6 +97,12 @@ export default { currentBranchCommit() { return this.currentBranch?.commit.id; }, + previewMode() { + return viewerInformationForPath(this.file.path); + }, + fileType() { + return this.previewMode?.id || ''; + }, }, watch: { file(newVal, oldVal) { @@ -165,6 +170,12 @@ export default { this.editor = Editor.create(this.editorOptions); } this.initEditor(); + + // listen in capture phase to be able to override Monaco's behaviour. + window.addEventListener('paste', this.onPaste, true); + }, + destroyed() { + window.removeEventListener('paste', this.onPaste, true); }, methods: { ...mapActions([ @@ -174,10 +185,10 @@ export default { 'setFileLanguage', 'setEditorPosition', 'setFileViewMode', - 'setFileEOL', 'updateViewer', 'removePendingTab', 'triggerFilesChange', + 'addTempImage', ]), initEditor() { if (this.shouldHideEditor && (this.file.content || this.file.raw)) { @@ -186,7 +197,7 @@ export default { this.editor.clearEditor(); - this.fetchFileData() + Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()]) .then(() => { this.createEditorInstance(); }) @@ -223,7 +234,7 @@ export default { if (this.viewer === viewerTypes.edit) { this.editor.createInstance(this.$refs.editor); } else { - this.editor.createDiffInstance(this.$refs.editor, !this.isReviewModeActive); + this.editor.createDiffInstance(this.$refs.editor); } this.setupEditor(); @@ -245,15 +256,15 @@ export default { this.editor.attachModel(this.model); } + this.model.updateOptions(this.rules); + this.model.onChange(model => { const { file } = model; + if (!file.active) return; - if (file.active) { - this.changeFileContent({ - path: file.path, - content: model.getModel().getValue(), - }); - } + const monacoModel = model.getModel(); + const content = monacoModel.getValue(); + this.changeFileContent({ path: file.path, content }); }); // Handle Cursor Position @@ -274,16 +285,51 @@ export default { fileLanguage: this.model.language, }); - // Get File eol - this.setFileEOL({ - eol: this.model.eol, - }); + this.$emit('editorSetup'); }, refreshEditorDimensions() { if (this.showEditor) { this.editor.updateDimensions(); } }, + fetchEditorconfigRules() { + return getRulesWithTraversal(this.file.path, path => { + const entry = this.entries[path]; + if (!entry) return Promise.resolve(null); + + const content = entry.content || entry.raw; + if (content) return Promise.resolve(content); + + return this.getFileData({ path: entry.path, makeFileActive: false }).then(() => + this.getRawFileData({ path: entry.path }), + ); + }).then(rules => { + this.rules = mapRulesToMonaco(rules); + }); + }, + onPaste(event) { + const editor = this.editor.instance; + const reImage = /^image\/(png|jpg|jpeg|gif)$/; + const file = event.clipboardData.files[0]; + + if (editor.hasTextFocus() && this.fileType === 'markdown' && reImage.test(file?.type)) { + // don't let the event be passed on to Monaco. + event.preventDefault(); + event.stopImmediatePropagation(); + + return readFileAsDataURL(file).then(content => { + const parentPath = getPathParent(this.file.path); + const path = `${parentPath ? `${parentPath}/` : ''}${file.name}`; + + return this.addTempImage({ name: path, rawPath: content }).then(({ name: fileName }) => { + this.editor.replaceSelectedText(`![${fileName}](./${fileName})`); + }); + }); + } + + // do nothing if no image is found in the clipboard + return Promise.resolve(); + }, }, viewerTypes, FILE_VIEW_MODE_EDITOR, @@ -301,16 +347,15 @@ export default { role="button" @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })" > - <template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template> - <template v-else>{{ __('Review') }}</template> + {{ __('Edit') }} </a> </li> - <li v-if="file.previewMode" :class="previewTabCSS"> + <li v-if="previewMode" :class="previewTabCSS"> <a href="javascript:void(0);" role="button" @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })" - >{{ file.previewMode.previewTitle }}</a + >{{ previewMode.previewTitle }}</a > </li> </ul> diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 1b7f149097b..47c75be3f7c 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,7 +1,6 @@ <script> import { mapActions } from 'vuex'; import RepoTab from './repo_tab.vue'; -import router from '../ide_router'; export default { components: { @@ -20,15 +19,6 @@ export default { type: String, required: true, }, - hasChanges: { - type: Boolean, - required: true, - }, - mergeRequestId: { - type: String, - required: false, - default: '', - }, }, methods: { ...mapActions(['updateViewer', 'removePendingTab']), @@ -37,7 +27,7 @@ export default { if (this.activeFile.pending) { return this.removePendingTab(this.activeFile).then(() => { - router.push(`/project${this.activeFile.url}`); + this.$router.push(`/project${this.activeFile.url}`); }); } @@ -49,7 +39,7 @@ export default { <template> <div class="multi-file-tabs"> - <ul ref="tabsScroller" class="list-unstyled append-bottom-0"> + <ul ref="tabsScroller" class="list-unstyled gl-mb-0"> <repo-tab v-for="tab in files" :key="tab.key" :tab="tab" /> </ul> </div> diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue index 86a4622401c..b49d743d877 100644 --- a/app/assets/javascripts/ide/components/resizable_panel.vue +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -1,6 +1,7 @@ <script> import { mapActions } from 'vuex'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import { SIDEBAR_MIN_WIDTH } from '../constants'; export default { components: { @@ -14,12 +15,17 @@ export default { minSize: { type: Number, required: false, - default: 340, + default: SIDEBAR_MIN_WIDTH, }, side: { type: String, required: true, }, + resizable: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -28,7 +34,7 @@ export default { }, computed: { panelStyle() { - if (!this.collapsed) { + if (this.resizable) { return { width: `${this.width}px`, }; @@ -45,9 +51,10 @@ export default { </script> <template> - <div :style="panelStyle" class="multi-file-commit-panel"> + <div class="gl-relative" :style="panelStyle"> <slot></slot> <panel-resizer + v-show="resizable" :size.sync="width" :start-size="initialWidth" :min-size="minSize" diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue new file mode 100644 index 00000000000..9841f1ece48 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue @@ -0,0 +1,71 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; + +export default { + components: { + GlLoadingIcon, + }, + props: { + isLoading: { + type: Boolean, + required: false, + default: true, + }, + isValid: { + type: Boolean, + required: false, + default: false, + }, + message: { + type: String, + required: false, + default: '', + }, + helpPath: { + type: String, + required: false, + default: '', + }, + illustrationPath: { + type: String, + required: false, + default: '', + }, + }, + methods: { + onStart() { + this.$emit('start'); + }, + }, +}; +</script> +<template> + <div class="text-center p-3"> + <div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div> + <h4>{{ __('Web Terminal') }}</h4> + <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default" /> + <template v-else> + <p>{{ __('Run tests against your code live using the Web Terminal') }}</p> + <p> + <button + :disabled="!isValid" + class="btn btn-info" + type="button" + data-qa-selector="start_web_terminal_button" + @click="onStart" + > + {{ __('Start Web Terminal') }} + </button> + </p> + <div v-if="!isValid && message" class="bs-callout text-left" v-html="message"></div> + <p v-else> + <a + v-if="helpPath" + :href="helpPath" + target="_blank" + v-text="__('Learn more about Web Terminal')" + ></a> + </p> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue new file mode 100644 index 00000000000..a8fe9ea6866 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/session.vue @@ -0,0 +1,53 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import Terminal from './terminal.vue'; +import { isEndingStatus } from '../../stores/modules/terminal/utils'; + +export default { + components: { + Terminal, + }, + computed: { + ...mapState('terminal', ['session']), + actionButton() { + if (isEndingStatus(this.session.status)) { + return { + action: () => this.restartSession(), + text: __('Restart Terminal'), + class: 'btn-primary', + }; + } + + return { + action: () => this.stopSession(), + text: __('Stop Terminal'), + class: 'btn-inverted btn-remove', + }; + }, + }, + methods: { + ...mapActions('terminal', ['restartSession', 'stopSession']), + }, +}; +</script> + +<template> + <div v-if="session" class="ide-terminal d-flex flex-column"> + <header class="ide-job-header d-flex align-items-center"> + <h5>{{ __('Web Terminal') }}</h5> + <div class="ml-auto align-self-center"> + <button + v-if="actionButton" + type="button" + class="btn btn-sm" + :class="actionButton.class" + @click="actionButton.action" + > + {{ actionButton.text }} + </button> + </div> + </header> + <terminal :terminal-path="session.terminalPath" :status="session.status" /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue new file mode 100644 index 00000000000..0ee4107f9ab --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/terminal.vue @@ -0,0 +1,117 @@ +<script> +import { mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import GLTerminal from '~/terminal/terminal'; +import TerminalControls from './terminal_controls.vue'; +import { RUNNING, STOPPING } from '../../stores/modules/terminal/constants'; +import { isStartingStatus } from '../../stores/modules/terminal/utils'; + +export default { + components: { + GlLoadingIcon, + TerminalControls, + }, + props: { + terminalPath: { + type: String, + required: false, + default: '', + }, + status: { + type: String, + required: true, + }, + }, + data() { + return { + glterminal: null, + canScrollUp: false, + canScrollDown: false, + }; + }, + computed: { + ...mapState(['panelResizing']), + loadingText() { + if (isStartingStatus(this.status)) { + return __('Starting...'); + } else if (this.status === STOPPING) { + return __('Stopping...'); + } + + return ''; + }, + }, + watch: { + panelResizing() { + if (!this.panelResizing && this.glterminal) { + this.glterminal.fit(); + } + }, + status() { + this.refresh(); + }, + terminalPath() { + this.refresh(); + }, + }, + beforeDestroy() { + this.destroyTerminal(); + }, + methods: { + refresh() { + if (this.status === RUNNING && this.terminalPath) { + this.createTerminal(); + } else if (this.status === STOPPING) { + this.stopTerminal(); + } + }, + createTerminal() { + this.destroyTerminal(); + this.glterminal = new GLTerminal(this.$refs.terminal); + this.glterminal.addScrollListener(({ canScrollUp, canScrollDown }) => { + this.canScrollUp = canScrollUp; + this.canScrollDown = canScrollDown; + }); + }, + destroyTerminal() { + if (this.glterminal) { + this.glterminal.dispose(); + this.glterminal = null; + } + }, + stopTerminal() { + if (this.glterminal) { + this.glterminal.disable(); + } + }, + }, +}; +</script> + +<template> + <div class="d-flex flex-column flex-fill min-height-0 pr-3"> + <div class="top-bar d-flex border-left-0 align-items-center"> + <div v-if="loadingText" data-qa-selector="loading_container"> + <gl-loading-icon :inline="true" /> + <span>{{ loadingText }}</span> + </div> + <terminal-controls + v-if="glterminal" + class="ml-auto" + :can-scroll-up="canScrollUp" + :can-scroll-down="canScrollDown" + @scroll-up="glterminal.scrollToTop()" + @scroll-down="glterminal.scrollToBottom()" + /> + </div> + <div class="terminal-wrapper d-flex flex-fill min-height-0"> + <div + ref="terminal" + class="ide-terminal-trace flex-fill min-height-0 w-100" + :data-project-path="terminalPath" + data-qa-selector="terminal_screen" + ></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal/terminal_controls.vue b/app/assets/javascripts/ide/components/terminal/terminal_controls.vue new file mode 100644 index 00000000000..4c13b4ef103 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/terminal_controls.vue @@ -0,0 +1,27 @@ +<script> +import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue'; + +export default { + components: { + ScrollButton, + }, + props: { + canScrollUp: { + type: Boolean, + required: false, + default: false, + }, + canScrollDown: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> +<template> + <div class="controllers"> + <scroll-button :disabled="!canScrollUp" direction="up" @click="$emit('scroll-up')" /> + <scroll-button :disabled="!canScrollDown" direction="down" @click="$emit('scroll-down')" /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal/view.vue b/app/assets/javascripts/ide/components/terminal/view.vue new file mode 100644 index 00000000000..db97e95eed9 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/view.vue @@ -0,0 +1,41 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import EmptyState from './empty_state.vue'; +import TerminalSession from './session.vue'; + +export default { + components: { + EmptyState, + TerminalSession, + }, + computed: { + ...mapState('terminal', ['isShowSplash', 'paths']), + ...mapGetters('terminal', ['allCheck']), + }, + methods: { + ...mapActions('terminal', ['startSession', 'hideSplash']), + start() { + this.startSession(); + this.hideSplash(); + }, + }, +}; +</script> + +<template> + <div class="h-100"> + <div v-if="isShowSplash" class="h-100 d-flex flex-column justify-content-center"> + <empty-state + :is-loading="allCheck.isLoading" + :is-valid="allCheck.isValid" + :message="allCheck.message" + :help-path="paths.webTerminalHelpPath" + :illustration-path="paths.webTerminalSvgPath" + @start="start()" + /> + </div> + <template v-else> + <terminal-session /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue new file mode 100644 index 00000000000..deb13b5615e --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue @@ -0,0 +1,76 @@ +<script> +import { throttle } from 'lodash'; +import { GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; +import { mapState } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import { + MSG_TERMINAL_SYNC_CONNECTING, + MSG_TERMINAL_SYNC_UPLOADING, + MSG_TERMINAL_SYNC_RUNNING, +} from '../../stores/modules/terminal_sync/messages'; + +export default { + components: { + Icon, + GlLoadingIcon, + }, + directives: { + 'gl-tooltip': GlTooltipDirective, + }, + data() { + return { isLoading: false }; + }, + computed: { + ...mapState('terminalSync', ['isError', 'isStarted', 'message']), + ...mapState('terminalSync', { + isLoadingState: 'isLoading', + }), + status() { + if (this.isLoading) { + return { + icon: '', + text: this.isStarted ? MSG_TERMINAL_SYNC_UPLOADING : MSG_TERMINAL_SYNC_CONNECTING, + }; + } else if (this.isError) { + return { + icon: 'warning', + text: this.message, + }; + } else if (this.isStarted) { + return { + icon: 'mobile-issue-close', + text: MSG_TERMINAL_SYNC_RUNNING, + }; + } + + return null; + }, + }, + watch: { + // We want to throttle the `isLoading` updates so that + // the user actually sees an indicator that changes are sent. + isLoadingState: throttle(function watchIsLoadingState(val) { + this.isLoading = val; + }, 150), + }, + created() { + this.isLoading = this.isLoadingState; + }, +}; +</script> + +<template> + <div + v-if="status" + v-gl-tooltip + :title="status.text" + role="note" + class="d-flex align-items-center" + > + <span>{{ __('Terminal') }}:</span> + <span class="square s16 d-flex-center ml-1" :aria-label="status.text"> + <gl-loading-icon v-if="isLoading" inline size="sm" class="d-flex-center" /> + <icon v-else-if="status.icon" :name="status.icon" :size="16" /> + </span> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue new file mode 100644 index 00000000000..afaf06f7f68 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue @@ -0,0 +1,22 @@ +<script> +import { mapState } from 'vuex'; +import TerminalSyncStatus from './terminal_sync_status.vue'; + +/** + * It is possible that the vuex module is not registered. + * + * This component will gracefully handle this so the actual one can simply use `mapState(moduleName, ...)`. + */ +export default { + components: { + TerminalSyncStatus, + }, + computed: { + ...mapState(['terminalSync']), + }, +}; +</script> + +<template> + <terminal-sync-status v-if="terminalSync" /> +</template> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index ae8550cba76..59b1969face 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -4,6 +4,10 @@ export const MAX_WINDOW_HEIGHT_COMPACT = 750; export const MAX_TITLE_LENGTH = 50; export const MAX_BODY_LENGTH = 72; +export const SIDEBAR_INIT_WIDTH = 340; +export const SIDEBAR_MIN_WIDTH = 340; +export const SIDEBAR_NAV_WIDTH = 60; + // File view modes export const FILE_VIEW_MODE_EDITOR = 'editor'; export const FILE_VIEW_MODE_PREVIEW = 'preview'; @@ -53,6 +57,7 @@ export const rightSidebarViews = { jobsDetail: { name: 'jobs-detail', keepAlive: false }, mergeRequestInfo: { name: 'merge-request-info', keepAlive: true }, clientSidePreview: { name: 'clientside', keepAlive: false }, + terminal: { name: 'terminal', keepAlive: true }, }; export const stageKeys = { @@ -89,3 +94,6 @@ export const commitActionTypes = { }; export const packageJsonPath = 'package.json'; + +export const SIDE_LEFT = 'left'; +export const SIDE_RIGHT = 'right'; diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 0fab3ee0f3b..152f77effa3 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -2,8 +2,8 @@ import Vue from 'vue'; import IdeRouter from '~/ide/ide_router_extension'; import { joinPaths } from '~/lib/utils/url_utility'; import flash from '~/flash'; -import store from './stores'; import { __ } from '~/locale'; +import { syncRouterAndStore } from './sync_router_and_store'; Vue.use(IdeRouter); @@ -33,80 +33,85 @@ const EmptyRouterComponent = { }, }; -const router = new IdeRouter({ - mode: 'history', - base: joinPaths(gon.relative_url_root || '', '/-/ide/'), - routes: [ - { - path: '/project/:namespace+/:project', - component: EmptyRouterComponent, - children: [ - { - path: ':targetmode(edit|tree|blob)/:branchid+/-/*', - component: EmptyRouterComponent, - }, - { - path: ':targetmode(edit|tree|blob)/:branchid+/', - redirect: to => joinPaths(to.path, '/-/'), - }, - { - path: ':targetmode(edit|tree|blob)', - redirect: to => joinPaths(to.path, '/master/-/'), - }, - { - path: 'merge_requests/:mrid', - component: EmptyRouterComponent, - }, - { - path: '', - redirect: to => joinPaths(to.path, '/edit/master/-/'), - }, - ], - }, - ], -}); +// eslint-disable-next-line import/prefer-default-export +export const createRouter = store => { + const router = new IdeRouter({ + mode: 'history', + base: joinPaths(gon.relative_url_root || '', '/-/ide/'), + routes: [ + { + path: '/project/:namespace+/:project', + component: EmptyRouterComponent, + children: [ + { + path: ':targetmode(edit|tree|blob)/:branchid+/-/*', + component: EmptyRouterComponent, + }, + { + path: ':targetmode(edit|tree|blob)/:branchid+/', + redirect: to => joinPaths(to.path, '/-/'), + }, + { + path: ':targetmode(edit|tree|blob)', + redirect: to => joinPaths(to.path, '/master/-/'), + }, + { + path: 'merge_requests/:mrid', + component: EmptyRouterComponent, + }, + { + path: '', + redirect: to => joinPaths(to.path, '/edit/master/-/'), + }, + ], + }, + ], + }); -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 basePath = to.params.pathMatch || ''; - const projectId = `${to.params.namespace}/${to.params.project}`; - const branchId = to.params.branchid; - const mergeRequestId = to.params.mrid; + 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 basePath = to.params.pathMatch || ''; + const projectId = `${to.params.namespace}/${to.params.project}`; + const branchId = to.params.branchid; + const mergeRequestId = to.params.mrid; - if (branchId) { - store.dispatch('openBranch', { - projectId, - branchId, - basePath, - }); - } else if (mergeRequestId) { - store.dispatch('openMergeRequest', { - projectId, - mergeRequestId, - targetProjectId: to.query.target_project, - }); - } - }) - .catch(e => { - flash( - __('Error while loading the project data. Please try again.'), - 'alert', - document, - null, - false, - true, - ); - throw e; - }); - } + if (branchId) { + store.dispatch('openBranch', { + projectId, + branchId, + basePath, + }); + } else if (mergeRequestId) { + store.dispatch('openMergeRequest', { + projectId, + mergeRequestId, + targetProjectId: to.query.target_project, + }); + } + }) + .catch(e => { + flash( + __('Error while loading the project data. Please try again.'), + 'alert', + document, + null, + false, + true, + ); + throw e; + }); + } - next(); -}); + next(); + }); -export default router; + syncRouterAndStore(router, store); + + return router; +}; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 55a0dd848c8..850cfcb05e3 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -4,7 +4,7 @@ import Translate from '~/vue_shared/translate'; import { identity } from 'lodash'; import ide from './components/ide.vue'; import store from './stores'; -import router from './ide_router'; +import { createRouter } from './ide_router'; import { parseBoolean } from '../lib/utils/common_utils'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import { DEFAULT_THEME } from './lib/themes'; @@ -32,6 +32,7 @@ export function initIde(el, options = {}) { if (!el) return null; const { rootComponent = ide, extendStore = identity } = options; + const router = createRouter(store); return new Vue({ el, diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index a15f04075d9..c5bb00c3dee 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -1,6 +1,8 @@ import { editor as monacoEditor, Uri } from 'monaco-editor'; import Disposable from './disposable'; import eventHub from '../../eventhub'; +import { trimTrailingWhitespace, insertFinalNewline } from '../../utils'; +import { defaultModelOptions } from '../editor_options'; export default class Model { constructor(file, head = null) { @@ -8,6 +10,7 @@ export default class Model { this.file = file; this.head = head; this.content = file.content !== '' || file.deleted ? file.content : file.raw; + this.options = { ...defaultModelOptions }; this.disposable.add( (this.originalModel = monacoEditor.createModel( @@ -50,10 +53,6 @@ export default class Model { return this.model.getModeId(); } - get eol() { - return this.model.getEOL() === '\n' ? 'LF' : 'CRLF'; - } - get path() { return this.file.key; } @@ -94,8 +93,32 @@ export default class Model { this.getModel().setValue(content); } + updateOptions(obj = {}) { + Object.assign(this.options, obj); + this.model.updateOptions(obj); + this.applyCustomOptions(); + } + + applyCustomOptions() { + this.updateNewContent( + Object.entries(this.options).reduce((content, [key, value]) => { + switch (key) { + case 'endOfLine': + this.model.pushEOL(value); + return this.model.getValue(); + case 'insertFinalNewline': + return value ? insertFinalNewline(content) : content; + case 'trimTrailingWhitespace': + return value ? trimTrailingWhitespace(content) : content; + default: + return content; + } + }, this.model.getValue()), + ); + } + dispose() { - this.disposable.dispose(); + if (!this.model.isDisposed()) this.applyCustomOptions(); this.events.forEach(cb => { if (typeof cb === 'function') cb(); @@ -106,5 +129,7 @@ export default class Model { eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent); eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent); + + this.disposable.dispose(); } } diff --git a/app/assets/javascripts/ide/lib/create_diff.js b/app/assets/javascripts/ide/lib/create_diff.js new file mode 100644 index 00000000000..3e915afdbcb --- /dev/null +++ b/app/assets/javascripts/ide/lib/create_diff.js @@ -0,0 +1,85 @@ +import { commitActionForFile } from '~/ide/stores/utils'; +import { commitActionTypes } from '~/ide/constants'; +import createFileDiff from './create_file_diff'; + +const getDeletedParents = (entries, file) => { + const parent = file.parentPath && entries[file.parentPath]; + + if (parent && parent.deleted) { + return [parent, ...getDeletedParents(entries, parent)]; + } + + return []; +}; + +const filesWithChanges = ({ stagedFiles = [], changedFiles = [], entries = {} }) => { + // We need changed files to overwrite staged, so put them at the end. + const changes = stagedFiles.concat(changedFiles).reduce((acc, file) => { + const key = file.path; + const action = commitActionForFile(file); + const prev = acc[key]; + + // If a file was deleted, which was previously added, then we should do nothing. + if (action === commitActionTypes.delete && prev && prev.action === commitActionTypes.create) { + delete acc[key]; + } else { + acc[key] = { action, file }; + } + + return acc; + }, {}); + + // We need to clean "move" actions, because we can only support 100% similarity moves at the moment. + // This is because the previous file's content might not be loaded. + Object.values(changes) + .filter(change => change.action === commitActionTypes.move) + .forEach(change => { + const prev = changes[change.file.prevPath]; + + if (!prev) { + return; + } + + if (change.file.content === prev.file.content) { + // If content is the same, continue with the move but don't do the prevPath's delete. + delete changes[change.file.prevPath]; + } else { + // Otherwise, treat the move as a delete / create. + Object.assign(change, { action: commitActionTypes.create }); + } + }); + + // Next, we need to add deleted directories by looking at the parents + Object.values(changes) + .filter(change => change.action === commitActionTypes.delete && change.file.parentPath) + .forEach(({ file }) => { + // Do nothing if we've already visited this directory. + if (changes[file.parentPath]) { + return; + } + + getDeletedParents(entries, file).forEach(parent => { + changes[parent.path] = { action: commitActionTypes.delete, file: parent }; + }); + }); + + return Object.values(changes); +}; + +const createDiff = state => { + const changes = filesWithChanges(state); + + const toDelete = changes.filter(x => x.action === commitActionTypes.delete).map(x => x.file.path); + + const patch = changes + .filter(x => x.action !== commitActionTypes.delete) + .map(({ file, action }) => createFileDiff(file, action)) + .join(''); + + return { + patch, + toDelete, + }; +}; + +export default createDiff; diff --git a/app/assets/javascripts/ide/lib/create_file_diff.js b/app/assets/javascripts/ide/lib/create_file_diff.js new file mode 100644 index 00000000000..5ae4993321c --- /dev/null +++ b/app/assets/javascripts/ide/lib/create_file_diff.js @@ -0,0 +1,112 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import { createTwoFilesPatch } from 'diff'; +import { commitActionTypes } from '~/ide/constants'; + +const DEV_NULL = '/dev/null'; +const DEFAULT_MODE = '100644'; +const NO_NEW_LINE = '\\ No newline at end of file'; +const NEW_LINE = '\n'; + +/** + * Cleans patch generated by `diff` package. + * + * - Removes "=======" separator added at the beginning + */ +const cleanTwoFilesPatch = text => text.replace(/^(=+\s*)/, ''); + +const endsWithNewLine = val => !val || val[val.length - 1] === NEW_LINE; + +const addEndingNewLine = val => (endsWithNewLine(val) ? val : val + NEW_LINE); + +const removeEndingNewLine = val => (endsWithNewLine(val) ? val.substr(0, val.length - 1) : val); + +const diffHead = (prevPath, newPath = '') => + `diff --git "a/${prevPath}" "b/${newPath || prevPath}"`; + +const createDiffBody = (path, content, isCreate) => { + if (!content) { + return ''; + } + + const prefix = isCreate ? '+' : '-'; + const fromPath = isCreate ? DEV_NULL : `a/${path}`; + const toPath = isCreate ? `b/${path}` : DEV_NULL; + + const hasNewLine = endsWithNewLine(content); + const lines = removeEndingNewLine(content).split(NEW_LINE); + + const chunkHead = isCreate ? `@@ -0,0 +1,${lines.length} @@` : `@@ -1,${lines.length} +0,0 @@`; + const chunk = lines + .map(line => `${prefix}${line}`) + .concat(!hasNewLine ? [NO_NEW_LINE] : []) + .join(NEW_LINE); + + return `--- ${fromPath} ++++ ${toPath} +${chunkHead} +${chunk}`; +}; + +const createMoveFileDiff = (prevPath, newPath) => `${diffHead(prevPath, newPath)} +rename from ${prevPath} +rename to ${newPath}`; + +const createNewFileDiff = (path, content) => { + const diff = createDiffBody(path, content, true); + + return `${diffHead(path)} +new file mode ${DEFAULT_MODE} +${diff}`; +}; + +const createDeleteFileDiff = (path, content) => { + const diff = createDiffBody(path, content, false); + + return `${diffHead(path)} +deleted file mode ${DEFAULT_MODE} +${diff}`; +}; + +const createUpdateFileDiff = (path, oldContent, newContent) => { + const patch = createTwoFilesPatch(`a/${path}`, `b/${path}`, oldContent, newContent); + + return `${diffHead(path)} +${cleanTwoFilesPatch(patch)}`; +}; + +const createFileDiffRaw = (file, action) => { + switch (action) { + case commitActionTypes.move: + return createMoveFileDiff(file.prevPath, file.path); + case commitActionTypes.create: + return createNewFileDiff(file.path, file.content); + case commitActionTypes.delete: + return createDeleteFileDiff(file.path, file.content); + case commitActionTypes.update: + return createUpdateFileDiff(file.path, file.raw || '', file.content); + default: + return ''; + } +}; + +/** + * Create a git diff for a single IDE file. + * + * ## Notes: + * When called with `commitActionType.move`, it assumes that the move + * is a 100% similarity move. No diff will be generated. This is because + * generating a move with changes is not support by the current IDE, since + * the source file might not have it's content loaded yet. + * + * When called with `commitActionType.delete`, it does not support + * deleting files with a mode different than 100644. For the IDE mirror, this + * isn't needed because deleting is handled outside the unified patch. + * + * ## References: + * - https://git-scm.com/docs/git-diff#_generating_patches_with_p + */ +const createFileDiff = (file, action) => + // It's important that the file diff ends in a new line - git expects this. + addEndingNewLine(createFileDiffRaw(file, action)); + +export default createFileDiff; diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index 234a7f903a1..35fcda6a6c5 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -50,10 +50,15 @@ export default class DirtyDiffController { } computeDiff(model) { + const originalModel = model.getOriginalModel(); + const newModel = model.getModel(); + + if (originalModel.isDisposed() || newModel.isDisposed()) return; + this.dirtyDiffWorker.postMessage({ path: model.path, - originalContent: model.getOriginalModel().getValue(), - newContent: model.getModel().getValue(), + originalContent: originalModel.getValue(), + newContent: newModel.getValue(), }); } diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js index 29e29d7fcd3..3a456b7c4d6 100644 --- a/app/assets/javascripts/ide/lib/diff/diff.js +++ b/app/assets/javascripts/ide/lib/diff/diff.js @@ -1,8 +1,15 @@ import { diffLines } from 'diff'; +import { defaultDiffOptions } from '../editor_options'; +// See: https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20 // eslint-disable-next-line import/prefer-default-export export const computeDiff = (originalContent, newContent) => { - const changes = diffLines(originalContent, newContent); + // prevent EOL changes from highlighting the entire file + const changes = diffLines( + originalContent.replace(/\r\n/g, '\n'), + newContent.replace(/\r\n/g, '\n'), + defaultDiffOptions, + ); let lineNumber = 1; return changes.reduce((acc, change) => { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 25224abd77c..4dfc27117c0 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -1,11 +1,11 @@ import { debounce } from 'lodash'; -import { editor as monacoEditor, KeyCode, KeyMod } from 'monaco-editor'; +import { editor as monacoEditor, KeyCode, KeyMod, Range } from 'monaco-editor'; import store from '../stores'; 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 { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options'; import { themes } from './themes'; import languages from './languages'; import keymap from './keymap.json'; @@ -37,6 +37,10 @@ export default class Editor { ...defaultEditorOptions, ...options, }; + this.diffOptions = { + ...defaultDiffEditorOptions, + ...options, + }; setupThemes(); registerLanguages(...languages); @@ -66,19 +70,14 @@ export default class Editor { } } - createDiffInstance(domElement, readOnly = true) { + createDiffInstance(domElement) { if (!this.instance) { clearDomElement(domElement); this.disposable.add( (this.instance = monacoEditor.createDiffEditor(domElement, { - ...this.options, - quickSuggestions: false, - occurrencesHighlight: false, + ...this.diffOptions, renderSideBySide: Editor.renderSideBySide(domElement), - readOnly, - renderLineHighlight: readOnly ? 'all' : 'none', - hideCursorInOverviewRuler: !readOnly, })), ); @@ -187,6 +186,21 @@ export default class Editor { }); } + replaceSelectedText(text) { + let selection = this.instance.getSelection(); + const range = new Range( + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn, + ); + + this.instance.executeEdits('', [{ range, text }]); + + selection = this.instance.getSelection(); + this.instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); + } + get isDiffEditorType() { return this.instance.getEditorType() === 'vs.editor.IDiffEditor'; } diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index dac2a8e8b51..f182a1ec50e 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -9,7 +9,27 @@ export const defaultEditorOptions = { wordWrap: 'on', }; -export default [ +export const defaultDiffOptions = { + ignoreWhitespace: false, +}; + +export const defaultDiffEditorOptions = { + ...defaultEditorOptions, + quickSuggestions: false, + occurrencesHighlight: false, + ignoreTrimWhitespace: false, + readOnly: false, + renderLineHighlight: 'none', + hideCursorInOverviewRuler: true, +}; + +export const defaultModelOptions = { + endOfLine: 0, + insertFinalNewline: true, + trimTrailingWhitespace: false, +}; + +export const editorOptions = [ { readOnly: model => Boolean(model.file.file_lock), quickSuggestions: model => !(model.language === 'markdown'), diff --git a/app/assets/javascripts/ide/lib/editorconfig/parser.js b/app/assets/javascripts/ide/lib/editorconfig/parser.js new file mode 100644 index 00000000000..a30a8cb868d --- /dev/null +++ b/app/assets/javascripts/ide/lib/editorconfig/parser.js @@ -0,0 +1,55 @@ +import { parseString } from 'editorconfig/src/lib/ini'; +import minimatch from 'minimatch'; +import { getPathParents } from '../../utils'; + +const dirname = path => path.replace(/\.editorconfig$/, ''); + +function isRootConfig(config) { + return config.some(([pattern, rules]) => !pattern && rules?.root === 'true'); +} + +function getRulesForSection(path, [pattern, rules]) { + if (!pattern) { + return {}; + } + if (minimatch(path, pattern, { matchBase: true })) { + return rules; + } + + return {}; +} + +function getRulesWithConfigs(filePath, configFiles = [], rules = {}) { + if (!configFiles.length) return rules; + + const [{ content, path: configPath }, ...nextConfigs] = configFiles; + const configDir = dirname(configPath); + + if (!filePath.startsWith(configDir)) return rules; + + const parsed = parseString(content); + const isRoot = isRootConfig(parsed); + const relativeFilePath = filePath.slice(configDir.length); + + const sectionRules = parsed.reduce( + (acc, section) => Object.assign(acc, getRulesForSection(relativeFilePath, section)), + {}, + ); + + // prefer existing rules by overwriting to section rules + const result = Object.assign(sectionRules, rules); + + return isRoot ? result : getRulesWithConfigs(filePath, nextConfigs, result); +} + +// eslint-disable-next-line import/prefer-default-export +export function getRulesWithTraversal(filePath, getFileContent) { + const editorconfigPaths = [ + ...getPathParents(filePath).map(x => `${x}/.editorconfig`), + '.editorconfig', + ]; + + return Promise.all( + editorconfigPaths.map(path => getFileContent(path).then(content => ({ path, content }))), + ).then(results => getRulesWithConfigs(filePath, results.filter(x => x.content))); +} diff --git a/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js b/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js new file mode 100644 index 00000000000..f9d5579511a --- /dev/null +++ b/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js @@ -0,0 +1,33 @@ +import { isBoolean, isNumber } from 'lodash'; + +const map = (key, validValues) => value => + value in validValues ? { [key]: validValues[value] } : {}; + +const bool = key => value => (isBoolean(value) ? { [key]: value } : {}); + +const int = (key, isValid) => value => + isNumber(value) && isValid(value) ? { [key]: Math.trunc(value) } : {}; + +const rulesMapper = { + indent_style: map('insertSpaces', { tab: false, space: true }), + indent_size: int('tabSize', n => n > 0), + tab_width: int('tabSize', n => n > 0), + trim_trailing_whitespace: bool('trimTrailingWhitespace'), + end_of_line: map('endOfLine', { crlf: 1, lf: 0 }), + insert_final_newline: bool('insertFinalNewline'), +}; + +const parseValue = x => { + let value = typeof x === 'string' ? x.toLowerCase() : x; + if (/^[0-9.-]+$/.test(value)) value = Number(value); + if (value === 'true') value = true; + if (value === 'false') value = false; + + return value; +}; + +export default function mapRulesToMonaco(rules) { + return Object.entries(rules).reduce((obj, [key, value]) => { + return Object.assign(obj, rulesMapper[key]?.(parseValue(value)) || {}); + }, {}); +} diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js index 26518a2abac..6d85e225fd5 100644 --- a/app/assets/javascripts/ide/lib/files.js +++ b/app/assets/javascripts/ide/lib/files.js @@ -19,7 +19,6 @@ export const decorateFiles = ({ branchId, tempFile = false, content = '', - base64 = false, binary = false, rawPath = '', }) => { @@ -49,7 +48,6 @@ export const decorateFiles = ({ path, url: `/${projectId}/tree/${branchId}/-/${path}/`, type: 'tree', - parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, tempFile, changed: tempFile, opened: tempFile, @@ -86,14 +84,11 @@ export const decorateFiles = ({ path, url: `/${projectId}/blob/${branchId}/-/${path}`, type: 'blob', - parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, tempFile, changed: tempFile, content, - base64, binary: (previewMode && previewMode.binary) || binary, rawPath, - previewMode, parentPath, }); diff --git a/app/assets/javascripts/ide/lib/languages/README.md b/app/assets/javascripts/ide/lib/languages/README.md new file mode 100644 index 00000000000..e4d1a4c7818 --- /dev/null +++ b/app/assets/javascripts/ide/lib/languages/README.md @@ -0,0 +1,21 @@ +# Web IDE Languages + +The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting. +The Web IDE currently supports all langauges defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository. + +## Adding New Languages + +While Monaco supports a wide variety of languages, there's always the chance that it's missing something. +You'll find a list of [unsupported languages in this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), which is the right place to add more if needed. + +Should you be willing to help us and add support to GitLab for any missing languages, here are the steps to do so: + +1. Create a new issue and add it to [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), if it doesn't already exist. +2. Create a new file in this folder called `{languageName}.js`, where `{languageName}` is the name of the language you want to add support for. +3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language. + - Example: The [`vue.js`](./vue.js) file in the current directory adds support for Vue.js Syntax Highlighting. +4. Add tests for the new langauge implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`. + - Example: See [`vue_spec.js`](spec/frontend/ide/lib/languages/vue_spec.js). +5. Create a [Merge Request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language. + +Thank you! diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js new file mode 100644 index 00000000000..a516c28ad7a --- /dev/null +++ b/app/assets/javascripts/ide/lib/mirror.js @@ -0,0 +1,154 @@ +import createDiff from './create_diff'; +import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + +export const SERVICE_NAME = 'webide-file-sync'; +export const PROTOCOL = 'webfilesync.gitlab.com'; +export const MSG_CONNECTION_ERROR = __('Could not connect to Web IDE file mirror service.'); + +// Before actually connecting to the service, we must delay a bit +// so that the service has sufficiently started. + +const noop = () => {}; +export const SERVICE_DELAY = 8000; + +const cancellableWait = time => { + let timeoutId = 0; + + const cancel = () => clearTimeout(timeoutId); + + const promise = new Promise(resolve => { + timeoutId = setTimeout(resolve, time); + }); + + return [promise, cancel]; +}; + +const isErrorResponse = error => error && error.code !== 0; + +const isErrorPayload = payload => payload && payload.status_code !== 200; + +const getErrorFromResponse = data => { + if (isErrorResponse(data.error)) { + return { message: data.error.Message }; + } else if (isErrorPayload(data.payload)) { + return { message: data.payload.error_message }; + } + + return null; +}; + +const getFullPath = path => mergeUrlParams({ service: SERVICE_NAME }, getWebSocketUrl(path)); + +const createWebSocket = fullPath => + new Promise((resolve, reject) => { + const socket = new WebSocket(fullPath, [PROTOCOL]); + const resetCallbacks = () => { + socket.onopen = null; + socket.onerror = null; + }; + + socket.onopen = () => { + resetCallbacks(); + resolve(socket); + }; + + socket.onerror = () => { + resetCallbacks(); + reject(new Error(MSG_CONNECTION_ERROR)); + }; + }); + +export const canConnect = ({ services = [] }) => services.some(name => name === SERVICE_NAME); + +export const createMirror = () => { + let socket = null; + let cancelHandler = noop; + let nextMessageHandler = noop; + + const cancelConnect = () => { + cancelHandler(); + cancelHandler = noop; + }; + + const onCancelConnect = fn => { + cancelHandler = fn; + }; + + const receiveMessage = ev => { + const handle = nextMessageHandler; + nextMessageHandler = noop; + handle(JSON.parse(ev.data)); + }; + + const onNextMessage = fn => { + nextMessageHandler = fn; + }; + + const waitForNextMessage = () => + new Promise((resolve, reject) => { + onNextMessage(data => { + const err = getErrorFromResponse(data); + + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + + const uploadDiff = ({ toDelete, patch }) => { + if (!socket) { + return Promise.resolve(); + } + + const response = waitForNextMessage(); + + const msg = { + code: 'EVENT', + namespace: '/files', + event: 'PATCH', + payload: { diff: patch, delete_files: toDelete }, + }; + + socket.send(JSON.stringify(msg)); + + return response; + }; + + return { + upload(state) { + return uploadDiff(createDiff(state)); + }, + connect(path) { + if (socket) { + this.disconnect(); + } + + const fullPath = getFullPath(path); + const [wait, cancelWait] = cancellableWait(SERVICE_DELAY); + + onCancelConnect(cancelWait); + + return wait + .then(() => createWebSocket(fullPath)) + .then(newSocket => { + socket = newSocket; + socket.onmessage = receiveMessage; + }); + }, + disconnect() { + cancelConnect(); + + if (!socket) { + return; + } + + socket.close(); + socket = null; + }, + }; +}; + +export default createMirror(); diff --git a/app/assets/javascripts/ide/services/terminals.js b/app/assets/javascripts/ide/services/terminals.js new file mode 100644 index 00000000000..17b4329037d --- /dev/null +++ b/app/assets/javascripts/ide/services/terminals.js @@ -0,0 +1,15 @@ +import axios from '~/lib/utils/axios_utils'; + +export const baseUrl = projectPath => `/${projectPath}/ide_terminals`; + +export const checkConfig = (projectPath, branch) => + axios.post(`${baseUrl(projectPath)}/check_config`, { + branch, + format: 'json', + }); + +export const create = (projectPath, branch) => + axios.post(baseUrl(projectPath), { + branch, + format: 'json', + }); diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index e32b5ac7bdc..c881f1221e5 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -7,7 +7,6 @@ import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; import service from '../services'; -import router from '../ide_router'; import eventHub from '../eventhub'; export const redirectToUrl = (self, url) => visitUrl(url); @@ -20,21 +19,25 @@ export const discardAllChanges = ({ state, commit, dispatch }) => { commit(types.REMOVE_ALL_CHANGES_FILES); }; -export const closeAllFiles = ({ state, dispatch }) => { - state.openFiles.forEach(file => dispatch('closeFile', file)); -}; - export const setResizingStatus = ({ commit }, resizing) => { commit(types.SET_RESIZING_STATUS, resizing); }; export const createTempEntry = ( { state, commit, dispatch, getters }, - { name, type, content = '', base64 = false, binary = false, rawPath = '' }, + { + name, + type, + content = '', + binary = false, + rawPath = '', + openFile = true, + makeFileActive = true, + }, ) => { const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; - if (state.entries[name] && !state.entries[name].deleted) { + if (getters.entryExists(name)) { flash( sprintf(__('The name "%{name}" is already taken in this directory.'), { name: name.split('/').pop(), @@ -46,7 +49,7 @@ export const createTempEntry = ( true, ); - return; + return undefined; } const data = decorateFiles({ @@ -56,7 +59,6 @@ export const createTempEntry = ( type, tempFile: true, content, - base64, binary, rawPath, }); @@ -69,18 +71,31 @@ export const createTempEntry = ( }); if (type === 'blob') { - commit(types.TOGGLE_FILE_OPEN, file.path); + if (openFile) commit(types.TOGGLE_FILE_OPEN, file.path); commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }); - dispatch('setFileActive', file.path); + if (openFile && makeFileActive) dispatch('setFileActive', file.path); dispatch('triggerFilesChange'); } if (parentPath && !state.entries[parentPath].opened) { commit(types.TOGGLE_TREE_OPEN, parentPath); } + + return file; }; +export const addTempImage = ({ dispatch, getters }, { name, rawPath = '' }) => + dispatch('createTempEntry', { + name: getters.getAvailableFileName(name), + type: 'blob', + content: rawPath.split('base64,')[1], + binary: true, + rawPath, + openFile: false, + makeFileActive: false, + }); + export const scrollToTab = () => { Vue.nextTick(() => { const tabs = document.getElementById('tabs'); @@ -239,7 +254,7 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name, } if (newEntry.opened) { - router.push(`/project${newEntry.url}`); + dispatch('router/push', `/project${newEntry.url}`, { root: true }); } } @@ -297,6 +312,3 @@ export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; export * from './actions/merge_request'; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index da7d4a44bde..47f9337a288 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -3,8 +3,7 @@ import { __ } from '~/locale'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; -import router from '../../ide_router'; -import { addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils'; +import { setPageTitleForFile } from '../utils'; import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { @@ -30,10 +29,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => { keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged', }); } else { - router.push(`/project${nextFileToOpen.url}`); + dispatch('router/push', `/project${nextFileToOpen.url}`, { root: true }); } } else if (!state.openFiles.length) { - router.push(`/project/${file.projectId}/tree/${file.branchId}/`); + dispatch('router/push', `/project/${file.projectId}/tree/${file.branchId}/`, { root: true }); } eventHub.$emit(`editor.update.model.dispose.${file.key}`); @@ -152,7 +151,7 @@ export const changeFileContent = ({ commit, state, getters }, { path, content }) const file = state.entries[path]; commit(types.UPDATE_FILE_CONTENT, { path, - content: addFinalNewlineIfNeeded(content), + content, }); const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); @@ -170,12 +169,6 @@ export const setFileLanguage = ({ getters, commit }, { 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, { @@ -226,7 +219,7 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) = if (!isDestructiveDiscard && file.path === getters.activeFile?.path) { dispatch('updateDelayViewerUpdated', true) .then(() => { - router.push(`/project${file.url}`); + dispatch('router/push', `/project${file.url}`, { root: true }); }) .catch(e => { throw e; @@ -275,14 +268,16 @@ export const unstageChange = ({ commit, dispatch, getters }, path) => { } }; -export const openPendingTab = ({ commit, getters, state }, { file, keyPrefix }) => { +export const openPendingTab = ({ commit, dispatch, getters, state }, { file, keyPrefix }) => { if (getters.activeFile && getters.activeFile.key === `${keyPrefix}-${file.key}`) return false; state.openFiles.forEach(f => eventHub.$emit(`editor.update.model.dispose.${f.key}`)); commit(types.ADD_PENDING_TAB, { file, keyPrefix }); - router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`); + dispatch('router/push', `/project/${file.projectId}/tree/${state.currentBranchId}/`, { + root: true, + }); return true; }; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 6c8fb9f90aa..d172bb31ae5 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -4,7 +4,6 @@ import { __, sprintf } from '~/locale'; import service from '../../services'; import api from '../../../api'; import * as types from '../mutation_types'; -import router from '../../ide_router'; export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) => new Promise((resolve, reject) => { @@ -57,7 +56,7 @@ export const createNewBranchFromDefault = ({ state, dispatch, getters }, branch) }) .then(() => { dispatch('setErrorMessage', null); - router.push(`${router.currentRoute.path}?${Date.now()}`); + window.location.reload(); }) .catch(() => { dispatch('setErrorMessage', { diff --git a/app/assets/javascripts/ide/stores/extend.js b/app/assets/javascripts/ide/stores/extend.js new file mode 100644 index 00000000000..1c1636cf6ca --- /dev/null +++ b/app/assets/javascripts/ide/stores/extend.js @@ -0,0 +1,14 @@ +import terminal from './plugins/terminal'; +import terminalSync from './plugins/terminal_sync'; + +const plugins = () => [ + terminal, + ...(gon.features && gon.features.buildServiceProxy ? [terminalSync] : []), +]; + +export default (store, el) => { + // plugins is actually an array of plugin factories, so we have to create first then call + plugins().forEach(plugin => plugin(el)(store)); + + return store; +}; diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 5d0a8570906..53734fa626b 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -50,9 +50,6 @@ export const emptyRepo = state => export const currentTree = state => state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; -export const hasChanges = state => - Boolean(state.changedFiles.length) || Boolean(state.stagedFiles.length); - export const hasMergeRequest = state => Boolean(state.currentMergeRequestId); export const allBlobs = state => @@ -162,5 +159,18 @@ export const canCreateMergeRequests = (state, getters) => export const canPushCode = (state, getters) => Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_PUSH_CODE]); -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; +export const entryExists = state => path => + Boolean(state.entries[path] && !state.entries[path].deleted); + +export const getAvailableFileName = (state, getters) => path => { + let newPath = path; + + while (getters.entryExists(newPath)) { + newPath = newPath.replace( + /([ _-]?)(\d*)(\..+?$|$)/, + (_, before, number, after) => `${before || '_'}${Number(number) + 1}${after}`, + ); + } + + return newPath; +}; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index 85550578e94..18c466cc93d 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -11,24 +11,27 @@ import branches from './modules/branches'; import fileTemplates from './modules/file_templates'; import paneModule from './modules/pane'; import clientsideModule from './modules/clientside'; +import routerModule from './modules/router'; Vue.use(Vuex); -export const createStore = () => - new Vuex.Store({ - state: state(), - actions, - mutations, - getters, - modules: { - commit: commitModule, - pipelines, - mergeRequests, - branches, - fileTemplates: fileTemplates(), - rightPane: paneModule(), - clientside: clientsideModule(), - }, - }); +export const createStoreOptions = () => ({ + state: state(), + actions, + mutations, + getters, + modules: { + commit: commitModule, + pipelines, + mergeRequests, + branches, + fileTemplates: fileTemplates(), + rightPane: paneModule(), + clientside: clientsideModule(), + router: routerModule, + }, +}); + +export const createStore = () => new Vuex.Store(createStoreOptions()); export default createStore(); diff --git a/app/assets/javascripts/ide/stores/modules/branches/index.js b/app/assets/javascripts/ide/stores/modules/branches/index.js index 04e7e0f08f1..deda95cd0c9 100644 --- a/app/assets/javascripts/ide/stores/modules/branches/index.js +++ b/app/assets/javascripts/ide/stores/modules/branches/index.js @@ -4,7 +4,7 @@ import mutations from './mutations'; export default { namespaced: true, - state: state(), + state, actions, mutations, }; diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js index eb3bcdff2ae..2bebf8b90ce 100644 --- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js +++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js @@ -8,5 +8,4 @@ export const pingUsage = ({ rootGetters }) => { return axios.post(url); }; -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; +export default pingUsage; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 592c7e15918..005bd0240e2 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -3,7 +3,6 @@ import flash from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; 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 consts from './constants'; @@ -196,8 +195,10 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo dispatch('updateViewer', 'editor', { root: true }); if (rootGetters.activeFile) { - router.push( + dispatch( + 'router/push', `/project/${rootState.currentProjectId}/blob/${branchName}/-/${rootGetters.activeFile.path}`, + { root: true }, ); } } @@ -234,6 +235,3 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo window.dispatchEvent(new Event('resize')); }); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index 413c4b0110d..37f887bcf0a 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -59,6 +59,3 @@ export const shouldDisableNewMrOption = (state, getters, rootState, rootGetters) export const shouldCreateMR = (state, getters) => state.shouldCreateMR && !getters.shouldDisableNewMrOption; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/index.js b/app/assets/javascripts/ide/stores/modules/commit/index.js index 3bf65b02847..5cec73bde2e 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/index.js +++ b/app/assets/javascripts/ide/stores/modules/commit/index.js @@ -5,7 +5,7 @@ import * as getters from './getters'; export default { namespaced: true, - state: state(), + state, mutations, actions, getters, diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js index 59ead8a3dcf..6b2c929cd44 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js @@ -117,6 +117,3 @@ export const undoFileTemplate = ({ dispatch, commit, rootGetters }) => { dispatch('discardFileChanges', file.path, { root: true }); } }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/pane/actions.js b/app/assets/javascripts/ide/stores/modules/pane/actions.js index a8fcdf539ec..b7cff368fe4 100644 --- a/app/assets/javascripts/ide/stores/modules/pane/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pane/actions.js @@ -25,6 +25,3 @@ export const open = ({ state, commit }, view) => { export const close = ({ commit }) => { commit(types.SET_OPEN, false); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/pane/getters.js b/app/assets/javascripts/ide/stores/modules/pane/getters.js index c346cf13689..7816172bb6f 100644 --- a/app/assets/javascripts/ide/stores/modules/pane/getters.js +++ b/app/assets/javascripts/ide/stores/modules/pane/getters.js @@ -1,4 +1,3 @@ -export const isActiveView = state => view => state.currentView === view; - -export const isAliveView = (state, getters) => view => - state.keepAliveViews[view] || (state.isOpen && getters.isActiveView(view)); +// eslint-disable-next-line import/prefer-default-export +export const isAliveView = state => view => + state.keepAliveViews[view] || (state.isOpen && state.currentView === view); diff --git a/app/assets/javascripts/ide/stores/modules/router/actions.js b/app/assets/javascripts/ide/stores/modules/router/actions.js new file mode 100644 index 00000000000..849067599f2 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/actions.js @@ -0,0 +1,6 @@ +import * as types from './mutation_types'; + +// eslint-disable-next-line import/prefer-default-export +export const push = ({ commit }, fullPath) => { + commit(types.PUSH, fullPath); +}; diff --git a/app/assets/javascripts/ide/stores/modules/router/index.js b/app/assets/javascripts/ide/stores/modules/router/index.js new file mode 100644 index 00000000000..68c81bb4509 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; + +export default { + namespaced: true, + state, + mutations, + actions, +}; diff --git a/app/assets/javascripts/ide/stores/modules/router/mutation_types.js b/app/assets/javascripts/ide/stores/modules/router/mutation_types.js new file mode 100644 index 00000000000..ae99073cc4c --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/mutation_types.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const PUSH = 'PUSH'; diff --git a/app/assets/javascripts/ide/stores/modules/router/mutations.js b/app/assets/javascripts/ide/stores/modules/router/mutations.js new file mode 100644 index 00000000000..471cace314c --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/mutations.js @@ -0,0 +1,7 @@ +import * as types from './mutation_types'; + +export default { + [types.PUSH](state, fullPath) { + state.fullPath = fullPath; + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/router/state.js b/app/assets/javascripts/ide/stores/modules/router/state.js new file mode 100644 index 00000000000..abb6c5239e4 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/state.js @@ -0,0 +1,3 @@ +export default () => ({ + fullPath: '', +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js new file mode 100644 index 00000000000..43b6650b241 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js @@ -0,0 +1,98 @@ +import Api from '~/api'; +import httpStatus from '~/lib/utils/http_status'; +import * as types from '../mutation_types'; +import * as messages from '../messages'; +import { CHECK_CONFIG, CHECK_RUNNERS, RETRY_RUNNERS_INTERVAL } from '../constants'; +import * as terminalService from '../../../../services/terminals'; + +export const requestConfigCheck = ({ commit }) => { + commit(types.REQUEST_CHECK, CHECK_CONFIG); +}; + +export const receiveConfigCheckSuccess = ({ commit }) => { + commit(types.SET_VISIBLE, true); + commit(types.RECEIVE_CHECK_SUCCESS, CHECK_CONFIG); +}; + +export const receiveConfigCheckError = ({ commit, state }, e) => { + const { status } = e.response; + const { paths } = state; + + const isVisible = status !== httpStatus.FORBIDDEN && status !== httpStatus.NOT_FOUND; + commit(types.SET_VISIBLE, isVisible); + + const message = messages.configCheckError(status, paths.webTerminalConfigHelpPath); + commit(types.RECEIVE_CHECK_ERROR, { type: CHECK_CONFIG, message }); +}; + +export const fetchConfigCheck = ({ dispatch, rootState, rootGetters }) => { + dispatch('requestConfigCheck'); + + const { currentBranchId } = rootState; + const { currentProject } = rootGetters; + + terminalService + .checkConfig(currentProject.path_with_namespace, currentBranchId) + .then(() => { + dispatch('receiveConfigCheckSuccess'); + }) + .catch(e => { + dispatch('receiveConfigCheckError', e); + }); +}; + +export const requestRunnersCheck = ({ commit }) => { + commit(types.REQUEST_CHECK, CHECK_RUNNERS); +}; + +export const receiveRunnersCheckSuccess = ({ commit, dispatch, state }, data) => { + if (data.length) { + commit(types.RECEIVE_CHECK_SUCCESS, CHECK_RUNNERS); + } else { + const { paths } = state; + + commit(types.RECEIVE_CHECK_ERROR, { + type: CHECK_RUNNERS, + message: messages.runnersCheckEmpty(paths.webTerminalRunnersHelpPath), + }); + + dispatch('retryRunnersCheck'); + } +}; + +export const receiveRunnersCheckError = ({ commit }) => { + commit(types.RECEIVE_CHECK_ERROR, { + type: CHECK_RUNNERS, + message: messages.UNEXPECTED_ERROR_RUNNERS, + }); +}; + +export const retryRunnersCheck = ({ dispatch, state }) => { + // if the overall check has failed, don't worry about retrying + const check = state.checks[CHECK_CONFIG]; + if (!check.isLoading && !check.isValid) { + return; + } + + setTimeout(() => { + dispatch('fetchRunnersCheck', { background: true }); + }, RETRY_RUNNERS_INTERVAL); +}; + +export const fetchRunnersCheck = ({ dispatch, rootGetters }, options = {}) => { + const { background = false } = options; + + if (!background) { + dispatch('requestRunnersCheck'); + } + + const { currentProject } = rootGetters; + + Api.projectRunners(currentProject.id, { params: { scope: 'active' } }) + .then(({ data }) => { + dispatch('receiveRunnersCheckSuccess', data); + }) + .catch(e => { + dispatch('receiveRunnersCheckError', e); + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js new file mode 100644 index 00000000000..112b3794114 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js @@ -0,0 +1,5 @@ +export * from './setup'; +export * from './checks'; +export * from './session_controls'; +export * from './session_status'; +export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js new file mode 100644 index 00000000000..d3dcb9dd125 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js @@ -0,0 +1,118 @@ +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; +import flash from '~/flash'; +import * as types from '../mutation_types'; +import * as messages from '../messages'; +import * as terminalService from '../../../../services/terminals'; +import { STARTING, STOPPING, STOPPED } from '../constants'; + +export const requestStartSession = ({ commit }) => { + commit(types.SET_SESSION_STATUS, STARTING); +}; + +export const receiveStartSessionSuccess = ({ commit, dispatch }, data) => { + commit(types.SET_SESSION, { + id: data.id, + status: data.status, + showPath: data.show_path, + cancelPath: data.cancel_path, + retryPath: data.retry_path, + terminalPath: data.terminal_path, + proxyWebsocketPath: data.proxy_websocket_path, + services: data.services, + }); + + dispatch('pollSessionStatus'); +}; + +export const receiveStartSessionError = ({ dispatch }) => { + flash(messages.UNEXPECTED_ERROR_STARTING); + dispatch('killSession'); +}; + +export const startSession = ({ state, dispatch, rootGetters, rootState }) => { + if (state.session && state.session.status === STARTING) { + return; + } + + const { currentProject } = rootGetters; + const { currentBranchId } = rootState; + + dispatch('requestStartSession'); + + terminalService + .create(currentProject.path_with_namespace, currentBranchId) + .then(({ data }) => { + dispatch('receiveStartSessionSuccess', data); + }) + .catch(error => { + dispatch('receiveStartSessionError', error); + }); +}; + +export const requestStopSession = ({ commit }) => { + commit(types.SET_SESSION_STATUS, STOPPING); +}; + +export const receiveStopSessionSuccess = ({ dispatch }) => { + dispatch('killSession'); +}; + +export const receiveStopSessionError = ({ dispatch }) => { + flash(messages.UNEXPECTED_ERROR_STOPPING); + dispatch('killSession'); +}; + +export const stopSession = ({ state, dispatch }) => { + const { cancelPath } = state.session; + + dispatch('requestStopSession'); + + axios + .post(cancelPath) + .then(() => { + dispatch('receiveStopSessionSuccess'); + }) + .catch(err => { + dispatch('receiveStopSessionError', err); + }); +}; + +export const killSession = ({ commit, dispatch }) => { + dispatch('stopPollingSessionStatus'); + commit(types.SET_SESSION_STATUS, STOPPED); +}; + +export const restartSession = ({ state, dispatch, rootState }) => { + const { status, retryPath } = state.session; + const { currentBranchId } = rootState; + + if (status !== STOPPED) { + return; + } + + if (!retryPath) { + dispatch('startSession'); + return; + } + + dispatch('requestStartSession'); + + axios + .post(retryPath, { branch: currentBranchId, format: 'json' }) + .then(({ data }) => { + dispatch('receiveStartSessionSuccess', data); + }) + .catch(error => { + const responseStatus = error.response && error.response.status; + // We may have removed the build, in this case we'll just create a new session + if ( + responseStatus === httpStatus.NOT_FOUND || + responseStatus === httpStatus.UNPROCESSABLE_ENTITY + ) { + dispatch('startSession'); + } else { + dispatch('receiveStartSessionError', error); + } + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js new file mode 100644 index 00000000000..59ba1605c47 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js @@ -0,0 +1,64 @@ +import axios from '~/lib/utils/axios_utils'; +import flash from '~/flash'; +import * as types from '../mutation_types'; +import * as messages from '../messages'; +import { isEndingStatus } from '../utils'; + +export const pollSessionStatus = ({ state, dispatch, commit }) => { + dispatch('stopPollingSessionStatus'); + dispatch('fetchSessionStatus'); + + const interval = setInterval(() => { + if (!state.session) { + dispatch('stopPollingSessionStatus'); + } else { + dispatch('fetchSessionStatus'); + } + }, 5000); + + commit(types.SET_SESSION_STATUS_INTERVAL, interval); +}; + +export const stopPollingSessionStatus = ({ state, commit }) => { + const { sessionStatusInterval } = state; + + if (!sessionStatusInterval) { + return; + } + + clearInterval(sessionStatusInterval); + + commit(types.SET_SESSION_STATUS_INTERVAL, 0); +}; + +export const receiveSessionStatusSuccess = ({ commit, dispatch }, data) => { + const status = data && data.status; + + commit(types.SET_SESSION_STATUS, status); + + if (isEndingStatus(status)) { + dispatch('killSession'); + } +}; + +export const receiveSessionStatusError = ({ dispatch }) => { + flash(messages.UNEXPECTED_ERROR_STATUS); + dispatch('killSession'); +}; + +export const fetchSessionStatus = ({ dispatch, state }) => { + if (!state.session) { + return; + } + + const { showPath } = state.session; + + axios + .get(showPath) + .then(({ data }) => { + dispatch('receiveSessionStatusSuccess', data); + }) + .catch(error => { + dispatch('receiveSessionStatusError', error); + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js new file mode 100644 index 00000000000..78ad94f8a91 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js @@ -0,0 +1,14 @@ +import * as types from '../mutation_types'; + +export const init = ({ dispatch }) => { + dispatch('fetchConfigCheck'); + dispatch('fetchRunnersCheck'); +}; + +export const hideSplash = ({ commit }) => { + commit(types.HIDE_SPLASH); +}; + +export const setPaths = ({ commit }, paths) => { + commit(types.SET_PATHS, paths); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/constants.js b/app/assets/javascripts/ide/stores/modules/terminal/constants.js new file mode 100644 index 00000000000..f7ae9d8f4ea --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/constants.js @@ -0,0 +1,9 @@ +export const CHECK_CONFIG = 'config'; +export const CHECK_RUNNERS = 'runners'; +export const RETRY_RUNNERS_INTERVAL = 10000; + +export const STARTING = 'starting'; +export const PENDING = 'pending'; +export const RUNNING = 'running'; +export const STOPPING = 'stopping'; +export const STOPPED = 'stopped'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/getters.js b/app/assets/javascripts/ide/stores/modules/terminal/getters.js new file mode 100644 index 00000000000..6d64ee4ab6e --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/getters.js @@ -0,0 +1,19 @@ +export const allCheck = state => { + const checks = Object.values(state.checks); + + if (checks.some(check => check.isLoading)) { + return { isLoading: true }; + } + + const invalidCheck = checks.find(check => !check.isValid); + const isValid = !invalidCheck; + const message = !invalidCheck ? '' : invalidCheck.message; + + return { + isLoading: false, + isValid, + message, + }; +}; + +export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/index.js b/app/assets/javascripts/ide/stores/modules/terminal/index.js new file mode 100644 index 00000000000..ef1289e1722 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/index.js @@ -0,0 +1,12 @@ +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +export default () => ({ + namespaced: true, + actions, + getters, + mutations, + state: state(), +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js new file mode 100644 index 00000000000..38c5a8a28d8 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js @@ -0,0 +1,55 @@ +import { escape } from 'lodash'; +import { __, sprintf } from '~/locale'; +import httpStatus from '~/lib/utils/http_status'; + +export const UNEXPECTED_ERROR_CONFIG = __( + 'An unexpected error occurred while checking the project environment.', +); +export const UNEXPECTED_ERROR_RUNNERS = __( + 'An unexpected error occurred while checking the project runners.', +); +export const UNEXPECTED_ERROR_STATUS = __( + 'An unexpected error occurred while communicating with the Web Terminal.', +); +export const UNEXPECTED_ERROR_STARTING = __( + 'An unexpected error occurred while starting the Web Terminal.', +); +export const UNEXPECTED_ERROR_STOPPING = __( + 'An unexpected error occurred while stopping the Web Terminal.', +); +export const EMPTY_RUNNERS = __( + 'Configure GitLab runners to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}', +); +export const ERROR_CONFIG = __( + 'Configure a <code>.gitlab-webide.yml</code> file in the <code>.gitlab</code> directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}', +); +export const ERROR_PERMISSION = __( + 'You do not have permission to run the Web Terminal. Please contact a project administrator.', +); + +export const configCheckError = (status, helpUrl) => { + if (status === httpStatus.UNPROCESSABLE_ENTITY) { + return sprintf( + ERROR_CONFIG, + { + helpStart: `<a href="${escape(helpUrl)}" target="_blank">`, + helpEnd: '</a>', + }, + false, + ); + } else if (status === httpStatus.FORBIDDEN) { + return ERROR_PERMISSION; + } + + return UNEXPECTED_ERROR_CONFIG; +}; + +export const runnersCheckEmpty = helpUrl => + sprintf( + EMPTY_RUNNERS, + { + helpStart: `<a href="${escape(helpUrl)}" target="_blank">`, + helpEnd: '</a>', + }, + false, + ); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js b/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js new file mode 100644 index 00000000000..b6a6f28abfa --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js @@ -0,0 +1,11 @@ +export const SET_VISIBLE = 'SET_VISIBLE'; +export const HIDE_SPLASH = 'HIDE_SPLASH'; +export const SET_PATHS = 'SET_PATHS'; + +export const REQUEST_CHECK = 'REQUEST_CHECK'; +export const RECEIVE_CHECK_SUCCESS = 'RECEIVE_CHECK_SUCCESS'; +export const RECEIVE_CHECK_ERROR = 'RECEIVE_CHECK_ERROR'; + +export const SET_SESSION = 'SET_SESSION'; +export const SET_SESSION_STATUS = 'SET_SESSION_STATUS'; +export const SET_SESSION_STATUS_INTERVAL = 'SET_SESSION_STATUS_INTERVAL'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/mutations.js b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js new file mode 100644 index 00000000000..37f40af9c2e --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js @@ -0,0 +1,64 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_VISIBLE](state, isVisible) { + Object.assign(state, { + isVisible, + }); + }, + [types.HIDE_SPLASH](state) { + Object.assign(state, { + isShowSplash: false, + }); + }, + [types.SET_PATHS](state, paths) { + Object.assign(state, { + paths, + }); + }, + [types.REQUEST_CHECK](state, type) { + Object.assign(state.checks, { + [type]: { + isLoading: true, + }, + }); + }, + [types.RECEIVE_CHECK_ERROR](state, { type, message }) { + Object.assign(state.checks, { + [type]: { + isLoading: false, + isValid: false, + message, + }, + }); + }, + [types.RECEIVE_CHECK_SUCCESS](state, type) { + Object.assign(state.checks, { + [type]: { + isLoading: false, + isValid: true, + message: null, + }, + }); + }, + [types.SET_SESSION](state, session) { + Object.assign(state, { + session, + }); + }, + [types.SET_SESSION_STATUS](state, status) { + const session = { + ...(state.session || {}), + status, + }; + + Object.assign(state, { + session, + }); + }, + [types.SET_SESSION_STATUS_INTERVAL](state, sessionStatusInterval) { + Object.assign(state, { + sessionStatusInterval, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/state.js b/app/assets/javascripts/ide/stores/modules/terminal/state.js new file mode 100644 index 00000000000..f35a10ed2fe --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/state.js @@ -0,0 +1,13 @@ +import { CHECK_CONFIG, CHECK_RUNNERS } from './constants'; + +export default () => ({ + checks: { + [CHECK_CONFIG]: { isLoading: true }, + [CHECK_RUNNERS]: { isLoading: true }, + }, + isVisible: false, + isShowSplash: true, + paths: {}, + session: null, + sessionStatusInterval: 0, +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/utils.js b/app/assets/javascripts/ide/stores/modules/terminal/utils.js new file mode 100644 index 00000000000..c30136b5277 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/utils.js @@ -0,0 +1,5 @@ +import { STARTING, PENDING, RUNNING } from './constants'; + +export const isStartingStatus = status => status === STARTING || status === PENDING; +export const isRunningStatus = status => status === RUNNING; +export const isEndingStatus = status => !isStartingStatus(status) && !isRunningStatus(status); diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js new file mode 100644 index 00000000000..2fee6b4e974 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js @@ -0,0 +1,41 @@ +import * as types from './mutation_types'; +import mirror, { canConnect } from '../../../lib/mirror'; + +export const upload = ({ rootState, commit }) => { + commit(types.START_LOADING); + + return mirror + .upload(rootState) + .then(() => { + commit(types.SET_SUCCESS); + }) + .catch(err => { + commit(types.SET_ERROR, err); + }); +}; + +export const stop = ({ commit }) => { + mirror.disconnect(); + + commit(types.STOP); +}; + +export const start = ({ rootState, commit }) => { + const { session } = rootState.terminal; + const path = session && session.proxyWebsocketPath; + if (!path || !canConnect(session)) { + return Promise.reject(); + } + + commit(types.START_LOADING); + + return mirror + .connect(path) + .then(() => { + commit(types.SET_SUCCESS); + }) + .catch(err => { + commit(types.SET_ERROR, err); + throw err; + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js new file mode 100644 index 00000000000..795c2fad724 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default () => ({ + namespaced: true, + actions, + mutations, + state: state(), +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js new file mode 100644 index 00000000000..e50e1a1406b --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js @@ -0,0 +1,5 @@ +import { __ } from '~/locale'; + +export const MSG_TERMINAL_SYNC_CONNECTING = __('Connecting to terminal sync service'); +export const MSG_TERMINAL_SYNC_UPLOADING = __('Uploading changes to terminal'); +export const MSG_TERMINAL_SYNC_RUNNING = __('Terminal sync service is running'); diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js new file mode 100644 index 00000000000..ec809540c18 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js @@ -0,0 +1,4 @@ +export const START_LOADING = 'START_LOADING'; +export const SET_ERROR = 'SET_ERROR'; +export const SET_SUCCESS = 'SET_SUCCESS'; +export const STOP = 'STOP'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js new file mode 100644 index 00000000000..70ed137776a --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js @@ -0,0 +1,22 @@ +import * as types from './mutation_types'; + +export default { + [types.START_LOADING](state) { + state.isLoading = true; + state.isError = false; + }, + [types.SET_ERROR](state, { message }) { + state.isLoading = false; + state.isError = true; + state.message = message; + }, + [types.SET_SUCCESS](state) { + state.isLoading = false; + state.isError = false; + state.isStarted = true; + }, + [types.STOP](state) { + state.isLoading = false; + state.isStarted = false; + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js new file mode 100644 index 00000000000..7ec3e38f675 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js @@ -0,0 +1,6 @@ +export default () => ({ + isLoading: false, + isStarted: false, + isError: false, + message: '', +}); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 5c78bfefa04..d94adc3760f 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -27,7 +27,6 @@ export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; export const SET_TREE_OPEN = 'SET_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'; @@ -41,7 +40,6 @@ 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_VIEWMODE = 'SET_FILE_VIEWMODE'; -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'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 12ac10df206..e827aacac13 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -65,14 +65,10 @@ export default { // NOTE: We can't clone `entry` in any of the below assignments because // we need `state.entries` and the `entry.tree` to reference the same object. - if (!foundEntry) { + if (!foundEntry || foundEntry.deleted) { Object.assign(state.entries, { [key]: entry, }); - } else if (foundEntry.deleted) { - Object.assign(state.entries, { - [key]: Object.assign(entry, { replaces: true }), - }); } else { const tree = entry.tree.filter( f => foundEntry.tree.find(e => e.path === f.path) === undefined, @@ -147,7 +143,6 @@ export default { raw: file.content, changed: Boolean(changedFile), staged: false, - replaces: false, lastCommitSha: lastCommit.commit.id, prevId: undefined, @@ -164,9 +159,6 @@ export default { Object.assign(state.entries[file.path], { rawPath: file.rawPath.replace(regex, file.path), - permalink: file.permalink.replace(regex, file.path), - commitsPath: file.commitsPath.replace(regex, file.path), - blamePath: file.blamePath.replace(regex, file.path), }); } }, @@ -207,8 +199,6 @@ export default { state.changedFiles = state.changedFiles.concat(entry); } } - - state.unusedSeal = false; }, [types.RENAME_ENTRY](state, { path, name, parentPath }) { const oldEntry = state.entries[path]; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 5c5920a3027..c90bc2a3320 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -99,11 +99,6 @@ export default { 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, @@ -153,13 +148,11 @@ export default { [types.ADD_FILE_TO_CHANGED](state, path) { Object.assign(state, { changedFiles: state.changedFiles.concat(state.entries[path]), - unusedSeal: false, }); }, [types.REMOVE_FILE_FROM_CHANGED](state, path) { Object.assign(state, { changedFiles: state.changedFiles.filter(f => f.path !== path), - unusedSeal: false, }); }, [types.STAGE_CHANGE](state, { path, diffInfo }) { @@ -175,7 +168,6 @@ export default { deleted: diffInfo.deleted, }), }), - unusedSeal: false, }); if (stagedFile) { diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index c8f14a680c2..cce43a99bd9 100644 --- a/app/assets/javascripts/ide/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -34,11 +34,6 @@ export default { Object.assign(selectedTree, { tree }); }, - [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/plugins/terminal.js b/app/assets/javascripts/ide/stores/plugins/terminal.js new file mode 100644 index 00000000000..66539c7bd4f --- /dev/null +++ b/app/assets/javascripts/ide/stores/plugins/terminal.js @@ -0,0 +1,25 @@ +import * as mutationTypes from '~/ide/stores/mutation_types'; +import terminalModule from '../modules/terminal'; + +function getPathsFromData(el) { + return { + webTerminalSvgPath: el.dataset.eeWebTerminalSvgPath, + webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath, + webTerminalConfigHelpPath: el.dataset.eeWebTerminalConfigHelpPath, + webTerminalRunnersHelpPath: el.dataset.eeWebTerminalRunnersHelpPath, + }; +} + +export default function createTerminalPlugin(el) { + return store => { + store.registerModule('terminal', terminalModule()); + + store.dispatch('terminal/setPaths', getPathsFromData(el)); + + store.subscribe(({ type }) => { + if (type === mutationTypes.SET_BRANCH_WORKING_REFERENCE) { + store.dispatch('terminal/init'); + } + }); + }; +} diff --git a/app/assets/javascripts/ide/stores/plugins/terminal_sync.js b/app/assets/javascripts/ide/stores/plugins/terminal_sync.js new file mode 100644 index 00000000000..c60bba4293a --- /dev/null +++ b/app/assets/javascripts/ide/stores/plugins/terminal_sync.js @@ -0,0 +1,49 @@ +import { debounce } from 'lodash'; +import eventHub from '~/ide/eventhub'; +import terminalSyncModule from '../modules/terminal_sync'; +import { isEndingStatus, isRunningStatus } from '../modules/terminal/utils'; + +const UPLOAD_DEBOUNCE = 200; + +/** + * Registers and controls the terminalSync vuex module based on IDE events. + * + * - Watches the terminal session status state to control start/stop. + * - Listens for file change event to control upload. + */ +export default function createMirrorPlugin() { + return store => { + store.registerModule('terminalSync', terminalSyncModule()); + + const upload = debounce(() => { + store.dispatch(`terminalSync/upload`); + }, UPLOAD_DEBOUNCE); + + const stop = () => { + store.dispatch(`terminalSync/stop`); + eventHub.$off('ide.files.change', upload); + }; + + const start = () => { + store + .dispatch(`terminalSync/start`) + .then(() => { + eventHub.$on('ide.files.change', upload); + }) + .catch(() => { + // error is handled in store + }); + }; + + store.watch( + x => x.terminal && x.terminal.session && x.terminal.session.status, + val => { + if (isRunningStatus(val)) { + start(); + } else if (isEndingStatus(val)) { + stop(); + } + }, + ); + }; +} diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 0c95c22e8f8..c1a83bf0726 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -9,10 +9,8 @@ export default () => ({ stagedFiles: [], endpoints: {}, lastCommitMsg: '', - lastCommitPath: '', loading: false, openFiles: [], - parentTreeUrl: '', trees: {}, projects: {}, panelResizing: false, @@ -20,7 +18,6 @@ export default () => ({ viewer: viewerTypes.edit, delayViewerUpdated: false, currentActivityView: leftSidebarViews.edit.name, - unusedSeal: true, fileFindVisible: false, links: {}, errorMessage: null, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 56671142bd4..1c5fe9fe9a5 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,5 +1,10 @@ import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants'; -import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility'; +import { + relativePathToAbsolute, + isAbsolute, + isRootRelative, + isBase64DataUrl, +} from '~/lib/utils/url_utility'; export const dataStructure = () => ({ id: '', @@ -19,8 +24,6 @@ export const dataStructure = () => ({ active: false, changed: false, staged: false, - replaces: false, - lastCommitPath: '', lastCommitSha: '', lastCommit: { id: '', @@ -29,23 +32,14 @@ export const dataStructure = () => ({ updatedAt: '', author: '', }, - blamePath: '', - commitsPath: '', - permalink: '', rawPath: '', binary: false, - html: '', raw: '', content: '', - parentTreeUrl: '', - renderError: false, - base64: false, editorRow: 1, editorColumn: 1, fileLanguage: '', - eol: '', viewMode: FILE_VIEW_MODE_EDITOR, - previewMode: null, size: 0, parentPath: null, lastOpenedAt: 0, @@ -63,19 +57,14 @@ export const decorateData = entity => { url, name, path, - renderError, content = '', tempFile = false, active = false, opened = false, changed = false, - parentTreeUrl = '', - base64 = false, binary = false, rawPath = '', - previewMode, file_lock, - html, parentPath = '', } = entity; @@ -91,25 +80,15 @@ export const decorateData = entity => { tempFile, opened, active, - parentTreeUrl, changed, - renderError, content, - base64, binary, rawPath, - previewMode, file_lock, - html, parentPath, }); }; -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; }; @@ -124,7 +103,7 @@ export const commitActionForFile = file => { return commitActionTypes.move; } else if (file.deleted) { return commitActionTypes.delete; - } else if (file.tempFile && !file.replaces) { + } else if (file.tempFile) { return commitActionTypes.create; } @@ -155,9 +134,8 @@ export const createCommitPayload = ({ file_path: f.path, previous_path: f.prevPath || undefined, content: f.prevPath && !f.changed ? null : f.content || undefined, - encoding: f.base64 ? 'base64' : 'text', - last_commit_id: - newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha, + encoding: isBase64DataUrl(f.rawPath) ? 'base64' : 'text', + last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha, })), start_sha: newBranch ? rootGetters.lastCommit.id : undefined, }); @@ -272,10 +250,6 @@ export const pathsAreEqual = (a, b) => { return cleanA === cleanB; }; -// if the contents of a file dont end with a newline, this function adds a newline -export const addFinalNewlineIfNeeded = content => - content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content; - export function extractMarkdownImagesFromEntries(mdFile, entries) { /** * Regex to identify an image tag in markdown, like: diff --git a/app/assets/javascripts/ide/sync_router_and_store.js b/app/assets/javascripts/ide/sync_router_and_store.js new file mode 100644 index 00000000000..1782c32b3b2 --- /dev/null +++ b/app/assets/javascripts/ide/sync_router_and_store.js @@ -0,0 +1,55 @@ +/* eslint-disable import/prefer-default-export */ +/** + * This method adds listeners to the given router and store and syncs their state with eachother + * + * ### Why? + * + * Previously the IDE had a circular dependency between a singleton router and a singleton store. + * This causes some integration testing headaches... + * + * At the time, the most effecient way to break this ciruclar dependency was to: + * + * - Replace the router with a factory function that receives a store reference + * - Have the store write to a certain state that can be watched by the router + * + * Hence... This helper function... + */ +export const syncRouterAndStore = (router, store) => { + const disposables = []; + + let currentPath = ''; + + // sync store to router + disposables.push( + store.watch( + state => state.router.fullPath, + fullPath => { + if (currentPath === fullPath) { + return; + } + + currentPath = fullPath; + + router.push(fullPath); + }, + ), + ); + + // sync router to store + disposables.push( + router.afterEach(to => { + if (currentPath === to.fullPath) { + return; + } + + currentPath = to.fullPath; + store.dispatch('router/push', currentPath, { root: true }); + }), + ); + + const unsync = () => { + disposables.forEach(fn => fn()); + }; + + return unsync; +}; diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 1ea2b199237..c28a2bd9f1d 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,4 +1,4 @@ -import { commitItemIconMap } from './constants'; +import { SIDE_LEFT, SIDE_RIGHT } from './constants'; import { languages } from 'monaco-editor'; import { flatten } from 'lodash'; @@ -53,16 +53,6 @@ export function isTextFile(content, mimeType, fileName) { return asciiRegex.test(content); } -export const getCommitIconMap = file => { - if (file.deleted) { - return commitItemIconMap.deleted; - } else if (file.tempFile && !file.prevPath) { - return commitItemIconMap.addition; - } - - return commitItemIconMap.modified; -}; - export const createPathWithExt = p => { const ext = p.lastIndexOf('.') >= 0 ? p.substring(p.lastIndexOf('.') + 1) : ''; @@ -84,3 +74,52 @@ export function registerLanguages(def, ...defs) { languages.setMonarchTokensProvider(languageId, def.language); languages.setLanguageConfiguration(languageId, def.conf); } + +export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT); + +export function trimTrailingWhitespace(content) { + return content.replace(/[^\S\r\n]+$/gm, ''); +} + +export function insertFinalNewline(content, eol = '\n') { + return content.slice(-eol.length) !== eol ? `${content}${eol}` : content; +} + +export function getPathParents(path, maxDepth = Infinity) { + const pathComponents = path.split('/'); + const paths = []; + + let depth = 0; + while (pathComponents.length && depth < maxDepth) { + pathComponents.pop(); + + let parentPath = pathComponents.join('/'); + if (parentPath.startsWith('/')) parentPath = parentPath.slice(1); + if (parentPath) paths.push(parentPath); + + depth += 1; + } + + return paths; +} + +export function getPathParent(path) { + return getPathParents(path, 1)[0]; +} + +/** + * Takes a file object and returns a data uri of its contents. + * + * @param {File} file + */ +export function readFileAsDataURL(file) { + return new Promise(resolve => { + const reader = new FileReader(); + reader.addEventListener('load', e => resolve(e.target.result), { once: true }); + reader.readAsDataURL(file); + }); +} + +export function getFileEOL(content = '') { + return content.includes('\r\n') ? 'CRLF' : 'LF'; +} |