summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/ide
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 11:18:50 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 11:18:50 +0000
commit8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch)
treea77e7fe7a93de11213032ed4ab1f33a3db51b738 /app/assets/javascripts/ide
parent00b35af3db1abfe813a778f643dad221aad51fca (diff)
downloadgitlab-ce-8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781.tar.gz
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'app/assets/javascripts/ide')
-rw-r--r--app/assets/javascripts/ide/commit_icon.js11
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue7
-rw-r--r--app/assets/javascripts/ide/components/branches/item.vue3
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue5
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue51
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue6
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue4
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue23
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue9
-rw-r--r--app/assets/javascripts/ide/components/ide_sidebar_nav.vue83
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_status_list.vue12
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/description.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue4
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/item.vue3
-rw-r--r--app/assets/javascripts/ide/components/mr_file_icon.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue11
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue1
-rw-r--r--app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue92
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue35
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue8
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue48
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue89
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue14
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue13
-rw-r--r--app/assets/javascripts/ide/components/terminal/empty_state.vue71
-rw-r--r--app/assets/javascripts/ide/components/terminal/session.vue53
-rw-r--r--app/assets/javascripts/ide/components/terminal/terminal.vue117
-rw-r--r--app/assets/javascripts/ide/components/terminal/terminal_controls.vue27
-rw-r--r--app/assets/javascripts/ide/components/terminal/view.vue41
-rw-r--r--app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue76
-rw-r--r--app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue22
-rw-r--r--app/assets/javascripts/ide/constants.js8
-rw-r--r--app/assets/javascripts/ide/ide_router.js153
-rw-r--r--app/assets/javascripts/ide/index.js3
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js35
-rw-r--r--app/assets/javascripts/ide/lib/create_diff.js85
-rw-r--r--app/assets/javascripts/ide/lib/create_file_diff.js112
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js9
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff.js9
-rw-r--r--app/assets/javascripts/ide/lib/editor.js32
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js22
-rw-r--r--app/assets/javascripts/ide/lib/editorconfig/parser.js55
-rw-r--r--app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js33
-rw-r--r--app/assets/javascripts/ide/lib/files.js5
-rw-r--r--app/assets/javascripts/ide/lib/languages/README.md21
-rw-r--r--app/assets/javascripts/ide/lib/mirror.js154
-rw-r--r--app/assets/javascripts/ide/services/terminals.js15
-rw-r--r--app/assets/javascripts/ide/stores/actions.js42
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js23
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js3
-rw-r--r--app/assets/javascripts/ide/stores/extend.js14
-rw-r--r--app/assets/javascripts/ide/stores/getters.js20
-rw-r--r--app/assets/javascripts/ide/stores/index.js35
-rw-r--r--app/assets/javascripts/ide/stores/modules/branches/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/clientside/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js8
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pane/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pane/getters.js7
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/actions.js6
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/index.js10
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/mutation_types.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/mutations.js7
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/state.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js98
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/index.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js118
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js64
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js14
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/constants.js9
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/getters.js19
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/index.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/messages.js55
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js11
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/mutations.js64
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/state.js13
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/utils.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js41
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/index.js10
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js22
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/state.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js12
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js8
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js5
-rw-r--r--app/assets/javascripts/ide/stores/plugins/terminal.js25
-rw-r--r--app/assets/javascripts/ide/stores/plugins/terminal_sync.js49
-rw-r--r--app/assets/javascripts/ide/stores/state.js3
-rw-r--r--app/assets/javascripts/ide/stores/utils.js44
-rw-r--r--app/assets/javascripts/ide/sync_router_and_store.js55
-rw-r--r--app/assets/javascripts/ide/utils.js61
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 }} &#x2192;
</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';
+}