summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/ide/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/ide/components')
-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
34 files changed, 712 insertions, 230 deletions
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>