summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2018-03-20 14:12:48 +0000
committerPhil Hughes <me@iamphill.com>2018-03-20 14:12:48 +0000
commitf527e6e1855f30cf5ca5cb834b2d20438299a70e (patch)
tree9d5dd3e33f86cf43c0b445822341896e20027f94 /app
parent68b914c9371e6e3fffc6afde23ca77a77ca688f2 (diff)
downloadgitlab-ce-f527e6e1855f30cf5ca5cb834b2d20438299a70e.tar.gz
Move IDE to CE
This also makes the IDE generally available
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/ide/components/changed_file_icon.vue31
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue65
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue66
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue35
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue60
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue94
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue91
-rw-r--r--app/assets/javascripts/ide/components/ide.vue111
-rw-r--r--app/assets/javascripts/ide/components/ide_context_bar.vue84
-rw-r--r--app/assets/javascripts/ide/components/ide_external_links.vue43
-rw-r--r--app/assets/javascripts/ide/components/ide_project_branches_tree.vue47
-rw-r--r--app/assets/javascripts/ide/components/ide_project_tree.vue54
-rw-r--r--app/assets/javascripts/ide/components/ide_repo_tree.vue41
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue51
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue60
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue111
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue99
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue75
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue174
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue161
-rw-r--r--app/assets/javascripts/ide/components/repo_file.vue127
-rw-r--r--app/assets/javascripts/ide/components/repo_file_buttons.vue61
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue39
-rw-r--r--app/assets/javascripts/ide/components/repo_loading_file.vue42
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue98
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue61
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue88
-rw-r--r--app/assets/javascripts/ide/eventhub.js3
-rw-r--r--app/assets/javascripts/ide/ide_router.js97
-rw-r--r--app/assets/javascripts/ide/index.js33
-rw-r--r--app/assets/javascripts/ide/lib/common/disposable.js14
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js90
-rw-r--r--app/assets/javascripts/ide/lib/common/model_manager.js51
-rw-r--r--app/assets/javascripts/ide/lib/decorations/controller.js45
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js72
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff.js30
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff_worker.js10
-rw-r--r--app/assets/javascripts/ide/lib/editor.js164
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js15
-rw-r--r--app/assets/javascripts/ide/lib/themes/gl_theme.js14
-rw-r--r--app/assets/javascripts/ide/monaco_loader.js16
-rw-r--r--app/assets/javascripts/ide/services/index.js55
-rw-r--r--app/assets/javascripts/ide/stores/actions.js121
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js146
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js49
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js93
-rw-r--r--app/assets/javascripts/ide/stores/getters.js30
-rw-r--r--app/assets/javascripts/ide/stores/index.js19
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js218
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/constants.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js24
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/index.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutation_types.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutations.js24
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/state.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js43
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js106
-rw-r--r--app/assets/javascripts/ide/stores/mutations/branch.js26
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js83
-rw-r--r--app/assets/javascripts/ide/stores/mutations/project.js23
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js38
-rw-r--r--app/assets/javascripts/ide/stores/state.js19
-rw-r--r--app/assets/javascripts/ide/stores/utils.js125
-rw-r--r--app/assets/javascripts/ide/stores/workers/files_decorator_worker.js91
-rw-r--r--app/assets/stylesheets/framework/images.scss34
-rw-r--r--app/assets/stylesheets/pages/repo.scss365
-rw-r--r--app/controllers/ide_controller.rb6
-rw-r--r--app/helpers/ide_helper.rb14
-rw-r--r--app/views/ide/index.html.haml12
-rw-r--r--app/views/projects/tree/_tree_header.html.haml4
70 files changed, 4421 insertions, 95 deletions
diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue
new file mode 100644
index 00000000000..0c54c992e51
--- /dev/null
+++ b/app/assets/javascripts/ide/components/changed_file_icon.vue
@@ -0,0 +1,31 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ changedIcon() {
+ return this.file.tempFile ? 'file-addition' : 'file-modified';
+ },
+ changedIconClass() {
+ return `multi-${this.changedIcon}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <icon
+ :name="changedIcon"
+ :size="12"
+ :css-classes="`ide-file-changed-icon ${changedIconClass}`"
+ />
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
new file mode 100644
index 00000000000..2cbd982af19
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -0,0 +1,65 @@
+<script>
+ import { mapState } from 'vuex';
+ import { sprintf, __ } from '~/locale';
+ import * as consts from '../../stores/modules/commit/constants';
+ import RadioGroup from './radio_group.vue';
+
+ export default {
+ components: {
+ RadioGroup,
+ },
+ computed: {
+ ...mapState([
+ 'currentBranchId',
+ ]),
+ newMergeRequestHelpText() {
+ return sprintf(
+ __('Creates a new branch from %{branchName} and re-directs to create a new merge request'),
+ { branchName: this.currentBranchId },
+ );
+ },
+ commitToCurrentBranchText() {
+ return sprintf(
+ __('Commit to %{branchName} branch'),
+ { branchName: `<strong>${this.currentBranchId}</strong>` },
+ false,
+ );
+ },
+ commitToNewBranchText() {
+ return sprintf(
+ __('Creates a new branch from %{branchName}'),
+ { branchName: this.currentBranchId },
+ );
+ },
+ },
+ commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
+ commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
+ commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
+ };
+</script>
+
+<template>
+ <div class="append-bottom-15 ide-commit-radios">
+ <radio-group
+ :value="$options.commitToCurrentBranch"
+ :checked="true"
+ >
+ <span
+ v-html="commitToCurrentBranchText"
+ >
+ </span>
+ </radio-group>
+ <radio-group
+ :value="$options.commitToNewBranch"
+ :label="__('Create a new branch')"
+ :show-input="true"
+ :help-text="commitToNewBranchText"
+ />
+ <radio-group
+ :value="$options.commitToNewBranchMR"
+ :label="__('Create a new branch and merge request')"
+ :show-input="true"
+ :help-text="newMergeRequestHelpText"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
new file mode 100644
index 00000000000..453208f3f19
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -0,0 +1,66 @@
+<script>
+ import { mapState } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import listItem from './list_item.vue';
+ import listCollapsed from './list_collapsed.vue';
+
+ export default {
+ components: {
+ icon,
+ listItem,
+ listCollapsed,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ fileList: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'currentProjectId',
+ 'currentBranchId',
+ 'rightPanelCollapsed',
+ ]),
+ isCommitInfoShown() {
+ return this.rightPanelCollapsed || this.fileList.length;
+ },
+ },
+ methods: {
+ toggleCollapsed() {
+ this.$emit('toggleCollapsed');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ :class="{
+ 'multi-file-commit-list': isCommitInfoShown
+ }"
+ >
+ <list-collapsed
+ v-if="rightPanelCollapsed"
+ />
+ <template v-else>
+ <ul
+ v-if="fileList.length"
+ class="list-unstyled append-bottom-0"
+ >
+ <li
+ v-for="file in fileList"
+ :key="file.key"
+ >
+ <list-item
+ :file="file"
+ />
+ </li>
+ </ul>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
new file mode 100644
index 00000000000..15918ac9631
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
@@ -0,0 +1,35 @@
+<script>
+ import { mapGetters } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ computed: {
+ ...mapGetters([
+ 'addedFiles',
+ 'modifiedFiles',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-list-collapsed text-center"
+ >
+ <icon
+ name="file-addition"
+ :size="18"
+ css-classes="multi-file-addition append-bottom-10"
+ />
+ {{ addedFiles.length }}
+ <icon
+ name="file-modified"
+ :size="18"
+ css-classes="multi-file-modified prepend-top-10 append-bottom-10"
+ />
+ {{ modifiedFiles.length }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
new file mode 100644
index 00000000000..18934af004a
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -0,0 +1,60 @@
+<script>
+ import { mapActions } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import router from '../../ide_router';
+
+ export default {
+ components: {
+ icon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ iconName() {
+ return this.file.tempFile ? 'file-addition' : 'file-modified';
+ },
+ iconClass() {
+ return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'discardFileChanges',
+ 'updateViewer',
+ ]),
+ openFileInEditor(file) {
+ this.updateViewer('diff');
+
+ router.push(`/project${file.url}`);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="multi-file-commit-list-item">
+ <button
+ type="button"
+ class="multi-file-commit-list-path"
+ @click="openFileInEditor(file)">
+ <span class="multi-file-commit-list-file-path">
+ <icon
+ :name="iconName"
+ :size="16"
+ :css-classes="iconClass"
+ />{{ file.path }}
+ </span>
+ </button>
+ <button
+ type="button"
+ class="btn btn-blank multi-file-discard-btn"
+ @click="discardFileChanges(file.path)"
+ >
+ Discard
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
new file mode 100644
index 00000000000..4310d762c78
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -0,0 +1,94 @@
+<script>
+ import { mapActions, mapState, mapGetters } from 'vuex';
+ import tooltip from '~/vue_shared/directives/tooltip';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ checked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showInput: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ ...mapState('commit', [
+ 'commitAction',
+ ]),
+ ...mapGetters('commit', [
+ 'newBranchName',
+ ]),
+ },
+ methods: {
+ ...mapActions('commit', [
+ 'updateCommitAction',
+ 'updateBranchName',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label>
+ <input
+ type="radio"
+ name="commit-action"
+ :value="value"
+ @change="updateCommitAction($event.target.value)"
+ :checked="checked"
+ v-once
+ />
+ <span class="prepend-left-10">
+ <template v-if="label">
+ {{ label }}
+ </template>
+ <slot v-else></slot>
+ <span
+ v-if="helpText"
+ v-tooltip
+ class="help-block inline"
+ :title="helpText"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ >
+ </i>
+ </span>
+ </span>
+ </label>
+ <div
+ v-if="commitAction === value && showInput"
+ class="ide-commit-new-branch"
+ >
+ <input
+ type="text"
+ class="form-control"
+ :placeholder="newBranchName"
+ @input="updateBranchName($event.target.value)"
+ />
+ </div>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
new file mode 100644
index 00000000000..170347881e0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -0,0 +1,91 @@
+<script>
+ import Icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ Icon,
+ },
+ props: {
+ hasChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ viewer: {
+ type: String,
+ required: true,
+ },
+ showShadow: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ changeMode(mode) {
+ this.$emit('click', mode);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="dropdown"
+ :class="{
+ shadow: showShadow,
+ }"
+ >
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ :class="{
+ 'btn-inverted': hasChanges,
+ }"
+ data-toggle="dropdown"
+ >
+ <template v-if="viewer === 'editor'">
+ {{ __('Editing') }}
+ </template>
+ <template v-else>
+ {{ __('Reviewing') }}
+ </template>
+ <icon
+ name="angle-down"
+ :size="12"
+ css-classes="caret-down"
+ />
+ </button>
+ <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left">
+ <ul>
+ <li>
+ <a
+ href="#"
+ @click.prevent="changeMode('editor')"
+ :class="{
+ 'is-active': viewer === 'editor',
+ }"
+ >
+ <strong class="dropdown-menu-inner-title">{{ __('Editing') }}</strong>
+ <span class="dropdown-menu-inner-content">
+ {{ __('View and edit lines') }}
+ </span>
+ </a>
+ </li>
+ <li>
+ <a
+ href="#"
+ @click.prevent="changeMode('diff')"
+ :class="{
+ 'is-active': viewer === 'diff',
+ }"
+ >
+ <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong>
+ <span class="dropdown-menu-inner-content">
+ {{ __('Compare changes with the last commit') }}
+ </span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
new file mode 100644
index 00000000000..015e750525a
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -0,0 +1,111 @@
+<script>
+ import { mapState, mapGetters } from 'vuex';
+ import ideSidebar from './ide_side_bar.vue';
+ import ideContextbar from './ide_context_bar.vue';
+ import repoTabs from './repo_tabs.vue';
+ import repoFileButtons from './repo_file_buttons.vue';
+ import ideStatusBar from './ide_status_bar.vue';
+ import repoEditor from './repo_editor.vue';
+
+ export default {
+ components: {
+ ideSidebar,
+ ideContextbar,
+ repoTabs,
+ repoFileButtons,
+ ideStatusBar,
+ repoEditor,
+ },
+ props: {
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['changedFiles', 'openFiles', 'viewer']),
+ ...mapGetters(['activeFile', 'hasChanges']),
+ },
+ mounted() {
+ const returnValue = 'Are you sure you want to lose unsaved changes?';
+ window.onbeforeunload = e => {
+ if (!this.changedFiles.length) return undefined;
+
+ Object.assign(e, {
+ returnValue,
+ });
+ return returnValue;
+ };
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="ide-view"
+ >
+ <ide-sidebar />
+ <div
+ class="multi-file-edit-pane"
+ >
+ <template
+ v-if="activeFile"
+ >
+ <repo-tabs
+ :files="openFiles"
+ :viewer="viewer"
+ :has-changes="hasChanges"
+ />
+ <repo-editor
+ class="multi-file-edit-pane-content"
+ :file="activeFile"
+ />
+ <repo-file-buttons
+ :file="activeFile"
+ />
+ <ide-status-bar
+ :file="activeFile"
+ />
+ </template>
+ <template
+ v-else
+ >
+ <div
+ v-once
+ class="ide-empty-state"
+ >
+ <div class="row js-empty-state">
+ <div class="col-xs-12">
+ <div class="svg-content svg-250">
+ <img :src="emptyStateSvgPath" />
+ </div>
+ </div>
+ <div class="col-xs-12">
+ <div class="text-content text-center">
+ <h4>
+ Welcome to the GitLab IDE
+ </h4>
+ <p>
+ You can select a file in the left sidebar to begin
+ editing and use the right sidebar to commit your changes.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+ <ide-contextbar
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue
new file mode 100644
index 00000000000..79a83b47994
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_context_bar.vue
@@ -0,0 +1,84 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import icon from '~/vue_shared/components/icon.vue';
+import panelResizer from '~/vue_shared/components/panel_resizer.vue';
+import repoCommitSection from './repo_commit_section.vue';
+import ResizablePanel from './resizable_panel.vue';
+
+export default {
+ components: {
+ repoCommitSection,
+ icon,
+ panelResizer,
+ ResizablePanel,
+ },
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['changedFiles', 'rightPanelCollapsed']),
+ ...mapGetters(['currentIcon']),
+ },
+ methods: {
+ ...mapActions(['setPanelCollapsedStatus']),
+ },
+};
+</script>
+
+<template>
+ <resizable-panel
+ :collapsible="true"
+ :initial-width="340"
+ side="right"
+ >
+ <div
+ class="multi-file-commit-panel-section"
+ >
+ <header
+ class="multi-file-commit-panel-header"
+ :class="{
+ 'is-collapsed': rightPanelCollapsed,
+ }"
+ >
+ <div
+ class="multi-file-commit-panel-header-title"
+ v-if="!rightPanelCollapsed"
+ >
+ <div
+ v-if="changedFiles.length"
+ >
+ <icon
+ name="list-bulleted"
+ :size="18"
+ />
+ Staged
+ </div>
+ </div>
+ <button
+ type="button"
+ class="btn btn-transparent multi-file-commit-panel-collapse-btn"
+ @click.stop="setPanelCollapsedStatus({
+ side: 'right',
+ collapsed: !rightPanelCollapsed,
+ })"
+ >
+ <icon
+ :name="currentIcon"
+ :size="18"
+ />
+ </button>
+ </header>
+ <repo-commit-section
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
+ </div>
+ </resizable-panel>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_external_links.vue b/app/assets/javascripts/ide/components/ide_external_links.vue
new file mode 100644
index 00000000000..c6f6e0d2348
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_external_links.vue
@@ -0,0 +1,43 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ icon,
+ },
+ props: {
+ projectUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ goBackUrl() {
+ return document.referrer || this.projectUrl;
+ },
+ },
+};
+</script>
+
+<template>
+ <nav
+ class="ide-external-links"
+ v-once
+ >
+ <p>
+ <a
+ :href="goBackUrl"
+ class="ide-sidebar-link"
+ >
+ <icon
+ :size="16"
+ class="append-right-8"
+ name="go-back"
+ />
+ <span class="ide-external-links-text">
+ {{ s__('Go back') }}
+ </span>
+ </a>
+ </p>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
new file mode 100644
index 00000000000..eb2749e6151
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
@@ -0,0 +1,47 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import repoTree from './ide_repo_tree.vue';
+ import newDropdown from './new_dropdown/index.vue';
+
+ export default {
+ components: {
+ repoTree,
+ icon,
+ newDropdown,
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ branch: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="branch-container">
+ <div class="branch-header">
+ <div class="branch-header-title str-truncated ref-name">
+ <icon
+ name="branch"
+ :size="12"
+ />
+ {{ branch.name }}
+ </div>
+ <div class="branch-header-btns">
+ <new-dropdown
+ :project-id="projectId"
+ :branch="branch.name"
+ path=""
+ />
+ </div>
+ </div>
+ <repo-tree
+ :tree="branch.tree"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue
new file mode 100644
index 00000000000..220db1abfb0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_project_tree.vue
@@ -0,0 +1,54 @@
+<script>
+import projectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
+import branchesTree from './ide_project_branches_tree.vue';
+import externalLinks from './ide_external_links.vue';
+
+export default {
+ components: {
+ branchesTree,
+ externalLinks,
+ projectAvatarImage,
+ },
+ props: {
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="projects-sidebar">
+ <div class="context-header">
+ <a
+ :title="project.name"
+ :href="project.web_url"
+ >
+ <div class="avatar-container s40 project-avatar">
+ <project-avatar-image
+ class="avatar-container project-avatar"
+ :link-href="project.path"
+ :img-src="project.avatar_url"
+ :img-alt="project.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="sidebar-context-title">
+ {{ project.name }}
+ </div>
+ </a>
+ </div>
+ <external-links
+ :project-url="project.web_url"
+ />
+ <div class="multi-file-commit-panel-inner-scroll">
+ <branches-tree
+ v-for="branch in project.branches"
+ :key="branch.name"
+ :project-id="project.path_with_namespace"
+ :branch="branch"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue
new file mode 100644
index 00000000000..e6af88e04bc
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue
@@ -0,0 +1,41 @@
+<script>
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import RepoFile from './repo_file.vue';
+
+export default {
+ components: {
+ RepoFile,
+ SkeletonLoadingContainer,
+ },
+ props: {
+ tree: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="ide-file-list"
+ >
+ <template v-if="tree.loading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
+ <template v-else>
+ <repo-file
+ v-for="file in tree.tree"
+ :key="file.key"
+ :file="file"
+ :level="0"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
new file mode 100644
index 00000000000..8cf1ccb4fce
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -0,0 +1,51 @@
+<script>
+ import { mapState, mapGetters } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import panelResizer from '~/vue_shared/components/panel_resizer.vue';
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+ import projectTree from './ide_project_tree.vue';
+ import ResizablePanel from './resizable_panel.vue';
+
+ export default {
+ components: {
+ projectTree,
+ icon,
+ panelResizer,
+ skeletonLoadingContainer,
+ ResizablePanel,
+ },
+ computed: {
+ ...mapState([
+ 'loading',
+ ]),
+ ...mapGetters([
+ 'projectsWithTrees',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <resizable-panel
+ :collapsible="false"
+ :initial-width="290"
+ side="left"
+ >
+ <div class="multi-file-commit-panel-inner">
+ <template v-if="loading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
+ <project-tree
+ v-for="project in projectsWithTrees"
+ :key="project.id"
+ :project="project"
+ />
+ </div>
+ </resizable-panel>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
new file mode 100644
index 00000000000..9c386896448
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -0,0 +1,60 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import timeAgoMixin from '~/vue_shared/mixins/timeago';
+
+ export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeAgoMixin,
+ ],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="ide-status-bar">
+ <div class="ref-name">
+ <icon
+ name="branch"
+ :size="12"
+ />
+ {{ file.branchId }}
+ </div>
+ <div>
+ <div v-if="file.lastCommit && file.lastCommit.id">
+ Last commit:
+ <a
+ v-tooltip
+ :title="file.lastCommit.message"
+ :href="file.lastCommit.url"
+ >
+ {{ timeFormated(file.lastCommit.updatedAt) }} by
+ {{ file.lastCommit.author }}
+ </a>
+ </div>
+ </div>
+ <div class="text-right">
+ {{ file.name }}
+ </div>
+ <div class="text-right">
+ {{ file.eol }}
+ </div>
+ <div class="text-right">
+ {{ file.editorRow }}:{{ file.editorColumn }}
+ </div>
+ <div class="text-right">
+ {{ file.fileLanguage }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
new file mode 100644
index 00000000000..769e9b79cad
--- /dev/null
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -0,0 +1,111 @@
+<script>
+ import { mapActions } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import newModal from './modal.vue';
+ import upload from './upload.vue';
+
+ export default {
+ components: {
+ icon,
+ newModal,
+ upload,
+ },
+ props: {
+ branch: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ openModal: false,
+ modalType: '',
+ dropdownOpen: false,
+ };
+ },
+ methods: {
+ ...mapActions([
+ 'createTempEntry',
+ ]),
+ createNewItem(type) {
+ this.modalType = type;
+ this.openModal = true;
+ this.dropdownOpen = false;
+ },
+ hideModal() {
+ this.openModal = false;
+ },
+ openDropdown() {
+ this.dropdownOpen = !this.dropdownOpen;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="ide-new-btn">
+ <div
+ class="dropdown"
+ :class="{
+ open: dropdownOpen,
+ }"
+ >
+ <button
+ type="button"
+ class="btn btn-sm btn-default dropdown-toggle add-to-tree"
+ aria-label="Create new file or directory"
+ @click.stop="openDropdown()"
+ >
+ <icon
+ name="plus"
+ :size="12"
+ css-classes="pull-left"
+ />
+ <icon
+ name="arrow-down"
+ :size="12"
+ css-classes="pull-left"
+ />
+ </button>
+ <ul class="dropdown-menu dropdown-menu-right">
+ <li>
+ <a
+ href="#"
+ role="button"
+ @click.stop.prevent="createNewItem('blob')"
+ >
+ {{ __('New file') }}
+ </a>
+ </li>
+ <li>
+ <upload
+ :branch-id="branch"
+ :path="path"
+ @create="createTempEntry"
+ />
+ </li>
+ <li>
+ <a
+ href="#"
+ role="button"
+ @click.stop.prevent="createNewItem('tree')"
+ >
+ {{ __('New directory') }}
+ </a>
+ </li>
+ </ul>
+ </div>
+ <new-modal
+ v-if="openModal"
+ :type="modalType"
+ :branch-id="branch"
+ :path="path"
+ @hide="hideModal"
+ @create="createTempEntry"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
new file mode 100644
index 00000000000..5723891d130
--- /dev/null
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -0,0 +1,99 @@
+<script>
+ import { __ } from '~/locale';
+ import modal from '~/vue_shared/components/modal.vue';
+
+ export default {
+ components: {
+ modal,
+ },
+ props: {
+ branchId: {
+ type: String,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ entryName: this.path !== '' ? `${this.path}/` : '',
+ };
+ },
+ computed: {
+ modalTitle() {
+ if (this.type === 'tree') {
+ return __('Create new directory');
+ }
+
+ return __('Create new file');
+ },
+ buttonLabel() {
+ if (this.type === 'tree') {
+ return __('Create directory');
+ }
+
+ return __('Create file');
+ },
+ formLabelName() {
+ if (this.type === 'tree') {
+ return __('Directory name');
+ }
+
+ return __('File name');
+ },
+ },
+ mounted() {
+ this.$refs.fieldName.focus();
+ },
+ methods: {
+ createEntryInStore() {
+ this.$emit('create', {
+ branchId: this.branchId,
+ name: this.entryName,
+ type: this.type,
+ });
+
+ this.hideModal();
+ },
+ hideModal() {
+ this.$emit('hide');
+ },
+ },
+ };
+</script>
+
+<template>
+ <modal
+ :title="modalTitle"
+ :primary-button-label="buttonLabel"
+ kind="success"
+ @cancel="hideModal"
+ @submit="createEntryInStore"
+ >
+ <form
+ class="form-horizontal"
+ slot="body"
+ @submit.prevent="createEntryInStore"
+ >
+ <fieldset class="form-group append-bottom-0">
+ <label class="label-light col-sm-3">
+ {{ formLabelName }}
+ </label>
+ <div class="col-sm-9">
+ <input
+ type="text"
+ class="form-control"
+ v-model="entryName"
+ ref="fieldName"
+ />
+ </div>
+ </fieldset>
+ </form>
+ </modal>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
new file mode 100644
index 00000000000..c165af5ce52
--- /dev/null
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -0,0 +1,75 @@
+<script>
+ export default {
+ props: {
+ branchId: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ mounted() {
+ this.$refs.fileUpload.addEventListener('change', this.openFile);
+ },
+ beforeDestroy() {
+ this.$refs.fileUpload.removeEventListener('change', this.openFile);
+ },
+ methods: {
+ createFile(target, file, isText) {
+ const { name } = file;
+ let { result } = target;
+
+ if (!isText) {
+ result = result.split('base64,')[1];
+ }
+
+ this.$emit('create', {
+ name: `${(this.path ? `${this.path}/` : '')}${name}`,
+ branchId: this.branchId,
+ type: 'blob',
+ content: result,
+ base64: !isText,
+ });
+ },
+ readFile(file) {
+ const reader = new FileReader();
+ const isText = file.type.match(/text.*/) !== null;
+
+ reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true });
+
+ if (isText) {
+ reader.readAsText(file);
+ } else {
+ reader.readAsDataURL(file);
+ }
+ },
+ openFile() {
+ Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file));
+ },
+ startFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <a
+ href="#"
+ role="button"
+ @click.stop.prevent="startFileUpload"
+ >
+ {{ __('Upload file') }}
+ </a>
+ <input
+ id="file-upload"
+ type="file"
+ class="hidden"
+ ref="fileUpload"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
new file mode 100644
index 00000000000..d772cab2d0e
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -0,0 +1,174 @@
+<script>
+import { mapState, mapActions, mapGetters } from 'vuex';
+import tooltip from '~/vue_shared/directives/tooltip';
+import icon from '~/vue_shared/components/icon.vue';
+import modal from '~/vue_shared/components/modal.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import commitFilesList from './commit_sidebar/list.vue';
+import * as consts from '../stores/modules/commit/constants';
+import Actions from './commit_sidebar/actions.vue';
+
+export default {
+ components: {
+ modal,
+ icon,
+ commitFilesList,
+ Actions,
+ LoadingButton,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'currentProjectId',
+ 'currentBranchId',
+ 'rightPanelCollapsed',
+ 'lastCommitMsg',
+ 'changedFiles',
+ ]),
+ ...mapState('commit', [
+ 'commitMessage',
+ 'submitCommitLoading',
+ ]),
+ ...mapGetters('commit', [
+ 'commitButtonDisabled',
+ 'discardDraftButtonDisabled',
+ 'branchName',
+ ]),
+ statusSvg() {
+ return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'setPanelCollapsedStatus',
+ ]),
+ ...mapActions('commit', [
+ 'updateCommitMessage',
+ 'discardDraft',
+ 'commitChanges',
+ 'updateCommitAction',
+ ]),
+ toggleCollapsed() {
+ this.setPanelCollapsedStatus({
+ side: 'right',
+ collapsed: !this.rightPanelCollapsed,
+ });
+ },
+ forceCreateNewBranch() {
+ return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH)
+ .then(() => this.commitChanges());
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-panel-section"
+ :class="{
+ 'multi-file-commit-empty-state-container': !changedFiles.length
+ }"
+ >
+ <modal
+ id="ide-create-branch-modal"
+ :primary-button-label="__('Create new branch')"
+ kind="success"
+ :title="__('Branch has changed')"
+ @submit="forceCreateNewBranch"
+ >
+ <template slot="body">
+ {{ __(`This branch has changed since you started editing.
+ Would you like to create a new branch?`) }}
+ </template>
+ </modal>
+ <commit-files-list
+ title="Staged"
+ :file-list="changedFiles"
+ :collapsed="rightPanelCollapsed"
+ @toggleCollapsed="toggleCollapsed"
+ />
+ <template
+ v-if="changedFiles.length"
+ >
+ <form
+ class="form-horizontal multi-file-commit-form"
+ @submit.prevent.stop="commitChanges"
+ v-if="!rightPanelCollapsed"
+ >
+ <div class="multi-file-commit-fieldset">
+ <textarea
+ class="form-control multi-file-commit-message"
+ name="commit-message"
+ :value="commitMessage"
+ :placeholder="__('Write a commit message...')"
+ @input="updateCommitMessage($event.target.value)"
+ >
+ </textarea>
+ </div>
+ <div class="clearfix prepend-top-15">
+ <actions />
+ <loading-button
+ :loading="submitCommitLoading"
+ :disabled="commitButtonDisabled"
+ container-class="btn btn-success btn-sm pull-left"
+ :label="__('Commit')"
+ @click="commitChanges"
+ />
+ <button
+ v-if="!discardDraftButtonDisabled"
+ type="button"
+ class="btn btn-default btn-sm pull-right"
+ @click="discardDraft"
+ >
+ {{ __('Discard draft') }}
+ </button>
+ </div>
+ </form>
+ </template>
+ <div
+ v-else-if="!rightPanelCollapsed"
+ class="row js-empty-state"
+ >
+ <div class="col-xs-10 col-xs-offset-1">
+ <div class="svg-content svg-80">
+ <img :src="statusSvg" />
+ </div>
+ </div>
+ <div class="col-xs-10 col-xs-offset-1">
+ <div
+ class="text-content text-center"
+ v-if="!lastCommitMsg"
+ >
+ <h4>
+ {{ __('No changes') }}
+ </h4>
+ <p>
+ {{ __('Edit files in the editor and commit changes here') }}
+ </p>
+ </div>
+ <div
+ class="text-content text-center"
+ v-else
+ >
+ <h4>
+ {{ __('All changes are committed') }}
+ </h4>
+ <p v-html="lastCommitMsg">
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
new file mode 100644
index 00000000000..e73d1ce839f
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -0,0 +1,161 @@
+<script>
+/* global monaco */
+import { mapState, mapActions } from 'vuex';
+import flash from '~/flash';
+import monacoLoader from '../monaco_loader';
+import Editor from '../lib/editor';
+
+export default {
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'leftPanelCollapsed',
+ 'rightPanelCollapsed',
+ 'viewer',
+ 'delayViewerUpdated',
+ ]),
+ shouldHideEditor() {
+ return this.file && this.file.binary && !this.file.raw;
+ },
+ },
+ watch: {
+ file(oldVal, newVal) {
+ if (newVal.path !== this.file.path) {
+ this.initMonaco();
+ }
+ },
+ leftPanelCollapsed() {
+ this.editor.updateDimensions();
+ },
+ rightPanelCollapsed() {
+ this.editor.updateDimensions();
+ },
+ viewer() {
+ this.createEditorInstance();
+ },
+ },
+ beforeDestroy() {
+ this.editor.dispose();
+ },
+ mounted() {
+ if (this.editor && monaco) {
+ this.initMonaco();
+ } else {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ this.editor = Editor.create(monaco);
+
+ this.initMonaco();
+ });
+ }
+ },
+ methods: {
+ ...mapActions([
+ 'getRawFileData',
+ 'changeFileContent',
+ 'setFileLanguage',
+ 'setEditorPosition',
+ 'setFileEOL',
+ 'updateViewer',
+ 'updateDelayViewerUpdated',
+ ]),
+ initMonaco() {
+ if (this.shouldHideEditor) return;
+
+ this.editor.clearEditor();
+
+ this.getRawFileData(this.file)
+ .then(() => {
+ const viewerPromise = this.delayViewerUpdated ? this.updateViewer('editor') : Promise.resolve();
+
+ return viewerPromise;
+ })
+ .then(() => {
+ this.updateDelayViewerUpdated(false);
+ this.createEditorInstance();
+ })
+ .catch((err) => {
+ flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true);
+ throw err;
+ });
+ },
+ createEditorInstance() {
+ this.editor.dispose();
+
+ this.$nextTick(() => {
+ if (this.viewer === 'editor') {
+ this.editor.createInstance(this.$refs.editor);
+ } else {
+ this.editor.createDiffInstance(this.$refs.editor);
+ }
+
+ this.setupEditor();
+ });
+ },
+ setupEditor() {
+ if (!this.file || !this.editor.instance) return;
+
+ this.model = this.editor.createModel(this.file);
+
+ this.editor.attachModel(this.model);
+
+ this.model.onChange((model) => {
+ const { file } = model;
+
+ if (file.active) {
+ this.changeFileContent({
+ path: file.path,
+ content: model.getModel().getValue(),
+ });
+ }
+ });
+
+ // Handle Cursor Position
+ this.editor.onPositionChange((instance, e) => {
+ this.setEditorPosition({
+ editorRow: e.position.lineNumber,
+ editorColumn: e.position.column,
+ });
+ });
+
+ this.editor.setPosition({
+ lineNumber: this.file.editorRow,
+ column: this.file.editorColumn,
+ });
+
+ // Handle File Language
+ this.setFileLanguage({
+ fileLanguage: this.model.language,
+ });
+
+ // Get File eol
+ this.setFileEOL({
+ eol: this.model.eol,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ id="ide"
+ class="blob-viewer-container blob-editor-container"
+ >
+ <div
+ v-if="shouldHideEditor"
+ v-html="file.html"
+ >
+ </div>
+ <div
+ v-show="!shouldHideEditor"
+ ref="editor"
+ class="multi-file-editor-holder"
+ >
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue
new file mode 100644
index 00000000000..03a40096bb0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file.vue
@@ -0,0 +1,127 @@
+<script>
+import { mapActions } from 'vuex';
+import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import fileIcon from '~/vue_shared/components/file_icon.vue';
+import router from '../ide_router';
+import newDropdown from './new_dropdown/index.vue';
+import fileStatusIcon from './repo_file_status_icon.vue';
+import changedFileIcon from './changed_file_icon.vue';
+
+export default {
+ name: 'RepoFile',
+ components: {
+ skeletonLoadingContainer,
+ newDropdown,
+ fileStatusIcon,
+ fileIcon,
+ changedFileIcon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ level: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ isTree() {
+ return this.file.type === 'tree';
+ },
+ isBlob() {
+ return this.file.type === 'blob';
+ },
+ levelIndentation() {
+ return {
+ marginLeft: `${this.level * 16}px`,
+ };
+ },
+ fileClass() {
+ return {
+ 'file-open': this.isBlob && this.file.opened,
+ 'file-active': this.isBlob && this.file.active,
+ folder: this.isTree,
+ };
+ },
+ },
+ updated() {
+ if (this.file.type === 'blob' && this.file.active) {
+ this.$el.scrollIntoView();
+ }
+ },
+ methods: {
+ ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']),
+ clickFile() {
+ // Manual Action if a tree is selected/opened
+ if (
+ this.isTree &&
+ this.$router.currentRoute.path === `/project${this.file.url}`
+ ) {
+ this.toggleTreeOpen(this.file.path);
+ }
+
+ const delayPromise = this.file.changed
+ ? Promise.resolve()
+ : this.updateDelayViewerUpdated(true);
+
+ return delayPromise.then(() => {
+ router.push(`/project${this.file.url}`);
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ class="file"
+ :class="fileClass"
+ >
+ <div
+ class="file-name"
+ @click="clickFile"
+ role="button"
+ >
+ <span
+ class="ide-file-name str-truncated"
+ :style="levelIndentation"
+ >
+ <file-icon
+ :file-name="file.name"
+ :loading="file.loading"
+ :folder="isTree"
+ :opened="file.opened"
+ :size="16"
+ />
+ {{ file.name }}
+ <file-status-icon
+ :file="file"
+ />
+ </span>
+ <changed-file-icon
+ :file="file"
+ v-if="file.changed || file.tempFile"
+ class="prepend-top-5 pull-right"
+ />
+ <new-dropdown
+ v-if="isTree"
+ :project-id="file.projectId"
+ :branch="file.branchId"
+ :path="file.path"
+ class="pull-right prepend-left-8"
+ />
+ </div>
+ </div>
+ <template v-if="file.opened">
+ <repo-file
+ v-for="childFile in file.tree"
+ :key="childFile.key"
+ :file="childFile"
+ :level="level + 1"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue
new file mode 100644
index 00000000000..4ea8cf7504b
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file_buttons.vue
@@ -0,0 +1,61 @@
+<script>
+export default {
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ showButtons() {
+ return this.file.rawPath ||
+ this.file.blamePath ||
+ this.file.commitsPath ||
+ this.file.permalink;
+ },
+ rawDownloadButtonLabel() {
+ return this.file.binary ? 'Download' : 'Raw';
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="showButtons"
+ class="multi-file-editor-btn-group"
+ >
+ <a
+ :href="file.rawPath"
+ target="_blank"
+ class="btn btn-default btn-sm raw"
+ rel="noopener noreferrer">
+ {{ rawDownloadButtonLabel }}
+ </a>
+
+ <div
+ class="btn-group"
+ role="group"
+ aria-label="File actions"
+ >
+ <a
+ :href="file.blamePath"
+ class="btn btn-default btn-sm blame"
+ >
+ Blame
+ </a>
+ <a
+ :href="file.commitsPath"
+ class="btn btn-default btn-sm history"
+ >
+ History
+ </a>
+ <a
+ :href="file.permalink"
+ class="btn btn-default btn-sm permalink"
+ >
+ Permalink
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
new file mode 100644
index 00000000000..25d311142d5
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
@@ -0,0 +1,39 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import '~/lib/utils/datetime_utility';
+
+ export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ lockTooltip() {
+ return `Locked by ${this.file.file_lock.user.name}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <span
+ v-if="file.file_lock"
+ v-tooltip
+ :title="lockTooltip"
+ data-container="body"
+ >
+ <icon
+ name="lock"
+ css-classes="file-status-icon"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue
new file mode 100644
index 00000000000..79af8c0b0c7
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_loading_file.vue
@@ -0,0 +1,42 @@
+<script>
+ import { mapState } from 'vuex';
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+
+ export default {
+ components: {
+ skeletonLoadingContainer,
+ },
+ computed: {
+ ...mapState([
+ 'leftPanelCollapsed',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <tr
+ class="loading-file"
+ aria-label="Loading files"
+ >
+ <td class="multi-file-table-col-name">
+ <skeleton-loading-container
+ :small="true"
+ />
+ </td>
+ <template v-if="!leftPanelCollapsed">
+ <td class="hidden-sm hidden-xs">
+ <skeleton-loading-container
+ :small="true"
+ />
+ </td>
+
+ <td class="hidden-xs">
+ <skeleton-loading-container
+ class="animation-container-right"
+ :small="true"
+ />
+ </td>
+ </template>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
new file mode 100644
index 00000000000..c337bc813e6
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -0,0 +1,98 @@
+<script>
+ import { mapActions } from 'vuex';
+
+ import fileIcon from '~/vue_shared/components/file_icon.vue';
+ import icon from '~/vue_shared/components/icon.vue';
+ import fileStatusIcon from './repo_file_status_icon.vue';
+ import changedFileIcon from './changed_file_icon.vue';
+
+ export default {
+ components: {
+ fileStatusIcon,
+ fileIcon,
+ icon,
+ changedFileIcon,
+ },
+ props: {
+ tab: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tabMouseOver: false,
+ };
+ },
+ computed: {
+ closeLabel() {
+ if (this.tab.changed || this.tab.tempFile) {
+ return `${this.tab.name} changed`;
+ }
+ return `Close ${this.tab.name}`;
+ },
+ showChangedIcon() {
+ return this.tab.changed ? !this.tabMouseOver : false;
+ },
+ },
+
+ methods: {
+ ...mapActions([
+ 'closeFile',
+ ]),
+ clickFile(tab) {
+ this.$router.push(`/project${tab.url}`);
+ },
+ mouseOverTab() {
+ if (this.tab.changed) {
+ this.tabMouseOver = true;
+ }
+ },
+ mouseOutTab() {
+ if (this.tab.changed) {
+ this.tabMouseOver = false;
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <li
+ @click="clickFile(tab)"
+ @mouseover="mouseOverTab"
+ @mouseout="mouseOutTab"
+ >
+ <button
+ type="button"
+ class="multi-file-tab-close"
+ @click.stop.prevent="closeFile(tab.path)"
+ :aria-label="closeLabel"
+ >
+ <icon
+ v-if="!showChangedIcon"
+ name="close"
+ :size="12"
+ />
+ <changed-file-icon
+ v-else
+ :file="tab"
+ />
+ </button>
+
+ <div
+ class="multi-file-tab"
+ :class="{active : tab.active }"
+ :title="tab.url"
+ >
+ <file-icon
+ :file-name="tab.name"
+ :size="16"
+ />
+ {{ tab.name }}
+ <file-status-icon
+ :file="tab"
+ />
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
new file mode 100644
index 00000000000..8ea64ddf84a
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -0,0 +1,61 @@
+<script>
+ import { mapActions } from 'vuex';
+ import RepoTab from './repo_tab.vue';
+ import EditorMode from './editor_mode_dropdown.vue';
+
+ export default {
+ components: {
+ RepoTab,
+ EditorMode,
+ },
+ props: {
+ files: {
+ type: Array,
+ required: true,
+ },
+ viewer: {
+ type: String,
+ required: true,
+ },
+ hasChanges: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showShadow: false,
+ };
+ },
+ updated() {
+ if (!this.$refs.tabsScroller) return;
+
+ this.showShadow =
+ this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth;
+ },
+ methods: {
+ ...mapActions(['updateViewer']),
+ },
+ };
+</script>
+
+<template>
+ <div class="multi-file-tabs">
+ <ul
+ class="list-unstyled append-bottom-0"
+ ref="tabsScroller"
+ >
+ <repo-tab
+ v-for="tab in files"
+ :key="tab.key"
+ :tab="tab"
+ />
+ </ul>
+ <editor-mode
+ :viewer="viewer"
+ :show-shadow="showShadow"
+ :has-changes="hasChanges"
+ @click="updateViewer"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
new file mode 100644
index 00000000000..faa690ecba0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -0,0 +1,88 @@
+<script>
+ import { mapActions, mapState } from 'vuex';
+ import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+
+ export default {
+ components: {
+ PanelResizer,
+ },
+ props: {
+ collapsible: {
+ type: Boolean,
+ required: true,
+ },
+ initialWidth: {
+ type: Number,
+ required: true,
+ },
+ minSize: {
+ type: Number,
+ required: false,
+ default: 200,
+ },
+ side: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ width: this.initialWidth,
+ };
+ },
+ computed: {
+ ...mapState({
+ collapsed(state) {
+ return state[`${this.side}PanelCollapsed`];
+ },
+ }),
+ panelStyle() {
+ if (!this.collapsed) {
+ return {
+ width: `${this.width}px`,
+ };
+ }
+
+ return {};
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'setPanelCollapsedStatus',
+ 'setResizingStatus',
+ ]),
+ toggleFullbarCollapsed() {
+ if (this.collapsed && this.collapsible) {
+ this.setPanelCollapsedStatus({
+ side: this.side,
+ collapsed: !this.collapsed,
+ });
+ }
+ },
+ },
+ maxSize: (window.innerWidth / 2),
+ };
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-panel"
+ :class="{
+ 'is-collapsed': collapsed && collapsible,
+ }"
+ :style="panelStyle"
+ @click="toggleFullbarCollapsed"
+ >
+ <slot></slot>
+ <panel-resizer
+ :size.sync="width"
+ :enabled="!collapsed"
+ :start-size="initialWidth"
+ :min-size="minSize"
+ :max-size="$options.maxSize"
+ @resize-start="setResizingStatus(true)"
+ @resize-end="setResizingStatus(false)"
+ :side="side === 'right' ? 'left' : 'right'"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/eventhub.js b/app/assets/javascripts/ide/eventhub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/ide/eventhub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
new file mode 100644
index 00000000000..048d5316922
--- /dev/null
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -0,0 +1,97 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import flash from '~/flash';
+import store from './stores';
+
+Vue.use(VueRouter);
+
+/**
+ * Routes below /-/ide/:
+
+/project/h5bp/html5-boilerplate/blob/master
+/project/h5bp/html5-boilerplate/blob/master/app/js/test.js
+
+/project/h5bp/html5-boilerplate/mr/123
+/project/h5bp/html5-boilerplate/mr/123/app/js/test.js
+
+/workspace/123
+/workspace/project/h5bp/html5-boilerplate/blob/my-special-branch
+/workspace/project/h5bp/html5-boilerplate/mr/123
+
+/ = /workspace
+
+/settings
+*/
+
+// Unfortunately Vue Router doesn't work without at least a fake component
+// If you do only data handling
+const EmptyRouterComponent = {
+ render(createElement) {
+ return createElement('div');
+ },
+};
+
+const router = new VueRouter({
+ mode: 'history',
+ base: `${gon.relative_url_root}/-/ide/`,
+ routes: [
+ {
+ path: '/project/:namespace/:project',
+ component: EmptyRouterComponent,
+ children: [
+ {
+ path: ':targetmode/:branch/*',
+ component: EmptyRouterComponent,
+ },
+ {
+ path: 'mr/:mrid',
+ component: EmptyRouterComponent,
+ },
+ ],
+ },
+ ],
+});
+
+router.beforeEach((to, from, next) => {
+ if (to.params.namespace && to.params.project) {
+ store.dispatch('getProjectData', {
+ namespace: to.params.namespace,
+ projectId: to.params.project,
+ })
+ .then(() => {
+ const fullProjectId = `${to.params.namespace}/${to.params.project}`;
+
+ if (to.params.branch) {
+ store.dispatch('getBranchData', {
+ projectId: fullProjectId,
+ branchId: to.params.branch,
+ });
+
+ store.dispatch('getFiles', {
+ projectId: fullProjectId,
+ branchId: to.params.branch,
+ })
+ .then(() => {
+ if (to.params[0]) {
+ const treeEntry = store.state.entries[to.params[0]];
+ if (treeEntry) {
+ store.dispatch('handleTreeEntryAction', treeEntry);
+ }
+ }
+ })
+ .catch((e) => {
+ flash('Error while loading the branch files. Please try again.', 'alert', document, null, false, true);
+ throw e;
+ });
+ }
+ })
+ .catch((e) => {
+ flash('Error while loading the project data. Please try again.', 'alert', document, null, false, true);
+ throw e;
+ });
+ }
+
+ next();
+});
+
+export default router;
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
new file mode 100644
index 00000000000..cbfb3dc54f2
--- /dev/null
+++ b/app/assets/javascripts/ide/index.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import ide from './components/ide.vue';
+import store from './stores';
+import router from './ide_router';
+
+function initIde(el) {
+ if (!el) return null;
+
+ return new Vue({
+ el,
+ store,
+ router,
+ components: {
+ ide,
+ },
+ render(createElement) {
+ return createElement('ide', {
+ props: {
+ emptyStateSvgPath: el.dataset.emptyStateSvgPath,
+ noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
+ committedStateSvgPath: el.dataset.committedStateSvgPath,
+ },
+ });
+ },
+ });
+}
+
+const ideElement = document.getElementById('ide');
+
+Vue.use(Translate);
+
+initIde(ideElement);
diff --git a/app/assets/javascripts/ide/lib/common/disposable.js b/app/assets/javascripts/ide/lib/common/disposable.js
new file mode 100644
index 00000000000..84b29bdb600
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/common/disposable.js
@@ -0,0 +1,14 @@
+export default class Disposable {
+ constructor() {
+ this.disposers = new Set();
+ }
+
+ add(...disposers) {
+ disposers.forEach(disposer => this.disposers.add(disposer));
+ }
+
+ dispose() {
+ this.disposers.forEach(disposer => disposer.dispose());
+ this.disposers.clear();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
new file mode 100644
index 00000000000..73cd684351c
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -0,0 +1,90 @@
+/* global monaco */
+import Disposable from './disposable';
+import eventHub from '../../eventhub';
+
+export default class Model {
+ constructor(monaco, file) {
+ this.monaco = monaco;
+ this.disposable = new Disposable();
+ this.file = file;
+ this.content = file.content !== '' ? file.content : file.raw;
+
+ this.disposable.add(
+ (this.originalModel = this.monaco.editor.createModel(
+ this.file.raw,
+ undefined,
+ new this.monaco.Uri(null, null, `original/${this.file.path}`),
+ )),
+ (this.model = this.monaco.editor.createModel(
+ this.content,
+ undefined,
+ new this.monaco.Uri(null, null, this.file.path),
+ )),
+ );
+
+ this.events = new Map();
+
+ this.updateContent = this.updateContent.bind(this);
+ this.dispose = this.dispose.bind(this);
+
+ eventHub.$on(`editor.update.model.dispose.${this.file.path}`, this.dispose);
+ eventHub.$on(
+ `editor.update.model.content.${this.file.path}`,
+ this.updateContent,
+ );
+ }
+
+ get url() {
+ return this.model.uri.toString();
+ }
+
+ get language() {
+ return this.model.getModeId();
+ }
+
+ get eol() {
+ return this.model.getEOL() === '\n' ? 'LF' : 'CRLF';
+ }
+
+ get path() {
+ return this.file.path;
+ }
+
+ getModel() {
+ return this.model;
+ }
+
+ getOriginalModel() {
+ return this.originalModel;
+ }
+
+ setValue(value) {
+ this.getModel().setValue(value);
+ }
+
+ onChange(cb) {
+ this.events.set(
+ this.path,
+ this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))),
+ );
+ }
+
+ updateContent(content) {
+ this.getOriginalModel().setValue(content);
+ this.getModel().setValue(content);
+ }
+
+ dispose() {
+ this.disposable.dispose();
+ this.events.clear();
+
+ eventHub.$off(
+ `editor.update.model.dispose.${this.file.path}`,
+ this.dispose,
+ );
+ eventHub.$off(
+ `editor.update.model.content.${this.file.path}`,
+ this.updateContent,
+ );
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js
new file mode 100644
index 00000000000..57d5e59a88b
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/common/model_manager.js
@@ -0,0 +1,51 @@
+import eventHub from '../../eventhub';
+import Disposable from './disposable';
+import Model from './model';
+
+export default class ModelManager {
+ constructor(monaco) {
+ this.monaco = monaco;
+ this.disposable = new Disposable();
+ this.models = new Map();
+ }
+
+ hasCachedModel(path) {
+ return this.models.has(path);
+ }
+
+ getModel(path) {
+ return this.models.get(path);
+ }
+
+ addModel(file) {
+ if (this.hasCachedModel(file.path)) {
+ return this.getModel(file.path);
+ }
+
+ const model = new Model(this.monaco, file);
+ this.models.set(model.path, model);
+ this.disposable.add(model);
+
+ eventHub.$on(
+ `editor.update.model.dispose.${file.path}`,
+ this.removeCachedModel.bind(this, file),
+ );
+
+ return model;
+ }
+
+ removeCachedModel(file) {
+ this.models.delete(file.path);
+
+ eventHub.$off(
+ `editor.update.model.dispose.${file.path}`,
+ this.removeCachedModel,
+ );
+ }
+
+ dispose() {
+ // dispose of all the models
+ this.disposable.dispose();
+ this.models.clear();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js
new file mode 100644
index 00000000000..42904774747
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/decorations/controller.js
@@ -0,0 +1,45 @@
+export default class DecorationsController {
+ constructor(editor) {
+ this.editor = editor;
+ this.decorations = new Map();
+ this.editorDecorations = new Map();
+ }
+
+ getAllDecorationsForModel(model) {
+ if (!this.decorations.has(model.url)) return [];
+
+ const modelDecorations = this.decorations.get(model.url);
+ const decorations = [];
+
+ modelDecorations.forEach(val => decorations.push(...val));
+
+ return decorations;
+ }
+
+ addDecorations(model, decorationsKey, decorations) {
+ const decorationMap = this.decorations.get(model.url) || new Map();
+
+ decorationMap.set(decorationsKey, decorations);
+
+ this.decorations.set(model.url, decorationMap);
+
+ this.decorate(model);
+ }
+
+ decorate(model) {
+ if (!this.editor.instance) return;
+
+ const decorations = this.getAllDecorationsForModel(model);
+ const oldDecorations = this.editorDecorations.get(model.url) || [];
+
+ this.editorDecorations.set(
+ model.url,
+ this.editor.instance.deltaDecorations(oldDecorations, decorations),
+ );
+ }
+
+ dispose() {
+ this.decorations.clear();
+ this.editorDecorations.clear();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js
new file mode 100644
index 00000000000..b136545ad11
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/diff/controller.js
@@ -0,0 +1,72 @@
+/* global monaco */
+import { throttle } from 'underscore';
+import DirtyDiffWorker from './diff_worker';
+import Disposable from '../common/disposable';
+
+export const getDiffChangeType = (change) => {
+ if (change.modified) {
+ return 'modified';
+ } else if (change.added) {
+ return 'added';
+ } else if (change.removed) {
+ return 'removed';
+ }
+
+ return '';
+};
+
+export const getDecorator = change => ({
+ range: new monaco.Range(
+ change.lineNumber,
+ 1,
+ change.endLineNumber,
+ 1,
+ ),
+ options: {
+ isWholeLine: true,
+ linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`,
+ },
+});
+
+export default class DirtyDiffController {
+ constructor(modelManager, decorationsController) {
+ this.disposable = new Disposable();
+ this.editorSimpleWorker = null;
+ this.modelManager = modelManager;
+ this.decorationsController = decorationsController;
+ this.dirtyDiffWorker = new DirtyDiffWorker();
+ this.throttledComputeDiff = throttle(this.computeDiff, 250);
+ this.decorate = this.decorate.bind(this);
+
+ this.dirtyDiffWorker.addEventListener('message', this.decorate);
+ }
+
+ attachModel(model) {
+ model.onChange(() => this.throttledComputeDiff(model));
+ }
+
+ computeDiff(model) {
+ this.dirtyDiffWorker.postMessage({
+ path: model.path,
+ originalContent: model.getOriginalModel().getValue(),
+ newContent: model.getModel().getValue(),
+ });
+ }
+
+ reDecorate(model) {
+ this.decorationsController.decorate(model);
+ }
+
+ decorate({ data }) {
+ const decorations = data.changes.map(change => getDecorator(change));
+ const model = this.modelManager.getModel(data.path);
+ this.decorationsController.addDecorations(model, 'dirtyDiff', decorations);
+ }
+
+ dispose() {
+ this.disposable.dispose();
+
+ this.dirtyDiffWorker.removeEventListener('message', this.decorate);
+ this.dirtyDiffWorker.terminate();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js
new file mode 100644
index 00000000000..0e37f5c4704
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/diff/diff.js
@@ -0,0 +1,30 @@
+import { diffLines } from 'diff';
+
+// eslint-disable-next-line import/prefer-default-export
+export const computeDiff = (originalContent, newContent) => {
+ const changes = diffLines(originalContent, newContent);
+
+ let lineNumber = 1;
+ return changes.reduce((acc, change) => {
+ const findOnLine = acc.find(c => c.lineNumber === lineNumber);
+
+ if (findOnLine) {
+ Object.assign(findOnLine, change, {
+ modified: true,
+ endLineNumber: (lineNumber + change.count) - 1,
+ });
+ } else if ('added' in change || 'removed' in change) {
+ acc.push(Object.assign({}, change, {
+ lineNumber,
+ modified: undefined,
+ endLineNumber: (lineNumber + change.count) - 1,
+ }));
+ }
+
+ if (!change.removed) {
+ lineNumber += change.count;
+ }
+
+ return acc;
+ }, []);
+};
diff --git a/app/assets/javascripts/ide/lib/diff/diff_worker.js b/app/assets/javascripts/ide/lib/diff/diff_worker.js
new file mode 100644
index 00000000000..e74c4046330
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/diff/diff_worker.js
@@ -0,0 +1,10 @@
+import { computeDiff } from './diff';
+
+self.addEventListener('message', (e) => {
+ const data = e.data;
+
+ self.postMessage({
+ path: data.path,
+ changes: computeDiff(data.originalContent, data.newContent),
+ });
+});
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
new file mode 100644
index 00000000000..38de2fe2b27
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -0,0 +1,164 @@
+import _ from 'underscore';
+import DecorationsController from './decorations/controller';
+import DirtyDiffController from './diff/controller';
+import Disposable from './common/disposable';
+import ModelManager from './common/model_manager';
+import editorOptions, { defaultEditorOptions } from './editor_options';
+import gitlabTheme from './themes/gl_theme';
+
+export const clearDomElement = el => {
+ if (!el || !el.firstChild) return;
+
+ while (el.firstChild) {
+ el.removeChild(el.firstChild);
+ }
+};
+
+export default class Editor {
+ static create(monaco) {
+ if (this.editorInstance) return this.editorInstance;
+
+ this.editorInstance = new Editor(monaco);
+
+ return this.editorInstance;
+ }
+
+ constructor(monaco) {
+ this.monaco = monaco;
+ this.currentModel = null;
+ this.instance = null;
+ this.dirtyDiffController = null;
+ this.disposable = new Disposable();
+ this.modelManager = new ModelManager(this.monaco);
+ this.decorationsController = new DecorationsController(this);
+
+ this.setupMonacoTheme();
+
+ this.debouncedUpdate = _.debounce(() => {
+ this.updateDimensions();
+ }, 200);
+ }
+
+ createInstance(domElement) {
+ if (!this.instance) {
+ clearDomElement(domElement);
+
+ this.disposable.add(
+ (this.instance = this.monaco.editor.create(domElement, {
+ ...defaultEditorOptions,
+ })),
+ (this.dirtyDiffController = new DirtyDiffController(
+ this.modelManager,
+ this.decorationsController,
+ )),
+ );
+
+ window.addEventListener('resize', this.debouncedUpdate, false);
+ }
+ }
+
+ createDiffInstance(domElement) {
+ if (!this.instance) {
+ clearDomElement(domElement);
+
+ this.disposable.add(
+ (this.instance = this.monaco.editor.createDiffEditor(domElement, {
+ ...defaultEditorOptions,
+ readOnly: true,
+ })),
+ );
+
+ window.addEventListener('resize', this.debouncedUpdate, false);
+ }
+ }
+
+ createModel(file) {
+ return this.modelManager.addModel(file);
+ }
+
+ attachModel(model) {
+ if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') {
+ this.instance.setModel({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+
+ return;
+ }
+
+ this.instance.setModel(model.getModel());
+ if (this.dirtyDiffController) this.dirtyDiffController.attachModel(model);
+
+ this.currentModel = model;
+
+ this.instance.updateOptions(
+ editorOptions.reduce((acc, obj) => {
+ Object.keys(obj).forEach(key => {
+ Object.assign(acc, {
+ [key]: obj[key](model),
+ });
+ });
+ return acc;
+ }, {}),
+ );
+
+ if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model);
+ }
+
+ setupMonacoTheme() {
+ this.monaco.editor.defineTheme(
+ gitlabTheme.themeName,
+ gitlabTheme.monacoTheme,
+ );
+
+ this.monaco.editor.setTheme('gitlab');
+ }
+
+ clearEditor() {
+ if (this.instance) {
+ this.instance.setModel(null);
+ }
+ }
+
+ dispose() {
+ window.removeEventListener('resize', this.debouncedUpdate);
+
+ // catch any potential errors with disposing the error
+ // this is mainly for tests caused by elements not existing
+ try {
+ this.disposable.dispose();
+
+ this.instance = null;
+ } catch (e) {
+ this.instance = null;
+
+ if (process.env.NODE_ENV !== 'test') {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ }
+ }
+
+ updateDimensions() {
+ this.instance.layout();
+ }
+
+ setPosition({ lineNumber, column }) {
+ this.instance.revealPositionInCenter({
+ lineNumber,
+ column,
+ });
+ this.instance.setPosition({
+ lineNumber,
+ column,
+ });
+ }
+
+ onPositionChange(cb) {
+ if (!this.instance.onDidChangeCursorPosition) return;
+
+ this.disposable.add(
+ this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)),
+ );
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
new file mode 100644
index 00000000000..d69d4b8c615
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -0,0 +1,15 @@
+export const defaultEditorOptions = {
+ model: null,
+ readOnly: false,
+ contextmenu: true,
+ scrollBeyondLastLine: false,
+ minimap: {
+ enabled: false,
+ },
+};
+
+export default [
+ {
+ readOnly: model => !!model.file.file_lock,
+ },
+];
diff --git a/app/assets/javascripts/ide/lib/themes/gl_theme.js b/app/assets/javascripts/ide/lib/themes/gl_theme.js
new file mode 100644
index 00000000000..2fc96250c7d
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/gl_theme.js
@@ -0,0 +1,14 @@
+export default {
+ themeName: 'gitlab',
+ monacoTheme: {
+ base: 'vs',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editorLineNumber.foreground': '#CCCCCC',
+ 'diffEditor.insertedTextBackground': '#ddfbe6',
+ 'diffEditor.removedTextBackground': '#f9d7dc',
+ 'editor.selectionBackground': '#aad6f8',
+ },
+ },
+};
diff --git a/app/assets/javascripts/ide/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js
new file mode 100644
index 00000000000..142a220097b
--- /dev/null
+++ b/app/assets/javascripts/ide/monaco_loader.js
@@ -0,0 +1,16 @@
+import monacoContext from 'monaco-editor/dev/vs/loader';
+
+monacoContext.require.config({
+ paths: {
+ vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase
+ },
+});
+
+// ignore CDN config and use local assets path for service worker which cannot be cross-domain
+const relativeRootPath = (gon && gon.relative_url_root) || '';
+const monacoPath = `${relativeRootPath}/assets/webpack/monaco-editor/vs`;
+window.MonacoEnvironment = { getWorkerUrl: () => `${monacoPath}/base/worker/workerMain.js` };
+
+// eslint-disable-next-line no-underscore-dangle
+window.__monaco_context__ = monacoContext;
+export default monacoContext.require;
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
new file mode 100644
index 00000000000..5f1fb6cf843
--- /dev/null
+++ b/app/assets/javascripts/ide/services/index.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+import Api from '~/api';
+
+Vue.use(VueResource);
+
+export default {
+ getTreeData(endpoint) {
+ return Vue.http.get(endpoint, { params: { format: 'json' } });
+ },
+ getFileData(endpoint) {
+ return Vue.http.get(endpoint, { params: { format: 'json' } });
+ },
+ getRawFileData(file) {
+ if (file.tempFile) {
+ return Promise.resolve(file.content);
+ }
+
+ if (file.raw) {
+ return Promise.resolve(file.raw);
+ }
+
+ return Vue.http.get(file.rawPath, { params: { format: 'json' } })
+ .then(res => res.text());
+ },
+ getProjectData(namespace, project) {
+ return Api.project(`${namespace}/${project}`);
+ },
+ getBranchData(projectId, currentBranchId) {
+ return Api.branchSingle(projectId, currentBranchId);
+ },
+ createBranch(projectId, payload) {
+ const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId);
+
+ return Vue.http.post(url, payload);
+ },
+ commit(projectId, payload) {
+ return Api.commitMultiple(projectId, payload);
+ },
+ getTreeLastCommit(endpoint) {
+ return Vue.http.get(endpoint, {
+ params: {
+ format: 'json',
+ },
+ });
+ },
+ getFiles(projectUrl, branchId) {
+ const url = `${projectUrl}/files/${branchId}`;
+ return Vue.http.get(url, {
+ params: {
+ format: 'json',
+ },
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
new file mode 100644
index 00000000000..7e920aa9f30
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -0,0 +1,121 @@
+import Vue from 'vue';
+import { visitUrl } from '~/lib/utils/url_utility';
+import flash from '~/flash';
+import * as types from './mutation_types';
+import FilesDecoratorWorker from './workers/files_decorator_worker';
+
+export const redirectToUrl = (_, url) => visitUrl(url);
+
+export const setInitialData = ({ commit }, data) =>
+ commit(types.SET_INITIAL_DATA, data);
+
+export const discardAllChanges = ({ state, commit, dispatch }) => {
+ state.changedFiles.forEach(file => {
+ commit(types.DISCARD_FILE_CHANGES, file.path);
+
+ if (file.tempFile) {
+ dispatch('closeFile', file.path);
+ }
+ });
+
+ commit(types.REMOVE_ALL_CHANGES_FILES);
+};
+
+export const closeAllFiles = ({ state, dispatch }) => {
+ state.openFiles.forEach(file => dispatch('closeFile', file.path));
+};
+
+export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
+ if (side === 'left') {
+ commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed);
+ } else {
+ commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed);
+ }
+};
+
+export const setResizingStatus = ({ commit }, resizing) => {
+ commit(types.SET_RESIZING_STATUS, resizing);
+};
+
+export const createTempEntry = (
+ { state, commit, dispatch },
+ { branchId, name, type, content = '', base64 = false },
+) =>
+ new Promise(resolve => {
+ const worker = new FilesDecoratorWorker();
+ const fullName =
+ name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
+
+ if (state.entries[name]) {
+ flash(
+ `The name "${name
+ .split('/')
+ .pop()}" is already taken in this directory.`,
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+
+ resolve();
+
+ return null;
+ }
+
+ worker.addEventListener('message', ({ data }) => {
+ const { file } = data;
+
+ worker.terminate();
+
+ commit(types.CREATE_TMP_ENTRY, {
+ data,
+ projectId: state.currentProjectId,
+ branchId,
+ });
+
+ if (type === 'blob') {
+ commit(types.TOGGLE_FILE_OPEN, file.path);
+ commit(types.ADD_FILE_TO_CHANGED, file.path);
+ dispatch('setFileActive', file.path);
+ }
+
+ resolve(file);
+ });
+
+ worker.postMessage({
+ data: [fullName],
+ projectId: state.currentProjectId,
+ branchId,
+ type,
+ tempFile: true,
+ base64,
+ content,
+ });
+
+ return null;
+ });
+
+export const scrollToTab = () => {
+ Vue.nextTick(() => {
+ const tabs = document.getElementById('tabs');
+
+ if (tabs) {
+ const tabEl = tabs.querySelector('.active .repo-tab');
+
+ tabEl.focus();
+ }
+ });
+};
+
+export const updateViewer = ({ commit }, viewer) => {
+ commit(types.UPDATE_VIEWER, viewer);
+};
+
+export const updateDelayViewerUpdated = ({ commit }, delay) => {
+ commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay);
+};
+
+export * from './actions/tree';
+export * from './actions/file';
+export * from './actions/project';
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
new file mode 100644
index 00000000000..ddc4b757bf9
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -0,0 +1,146 @@
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import flash from '~/flash';
+import eventHub from '../../eventhub';
+import service from '../../services';
+import * as types from '../mutation_types';
+import router from '../../ide_router';
+import { setPageTitle } from '../utils';
+
+export const closeFile = ({ commit, state, getters, dispatch }, path) => {
+ const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path);
+ const file = state.entries[path];
+ const fileWasActive = file.active;
+
+ commit(types.TOGGLE_FILE_OPEN, path);
+ commit(types.SET_FILE_ACTIVE, { path, active: false });
+
+ if (state.openFiles.length > 0 && fileWasActive) {
+ const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1;
+ const nextFileToOpen = state.entries[state.openFiles[nextIndexToOpen].path];
+
+ router.push(`/project${nextFileToOpen.url}`);
+ } else if (!state.openFiles.length) {
+ router.push(`/project/${file.projectId}/tree/${file.branchId}/`);
+ }
+
+ eventHub.$emit(`editor.update.model.dispose.${file.path}`);
+};
+
+export const setFileActive = ({ commit, state, getters, dispatch }, path) => {
+ const file = state.entries[path];
+ const currentActiveFile = getters.activeFile;
+
+ if (file.active) return;
+
+ if (currentActiveFile) {
+ commit(types.SET_FILE_ACTIVE, {
+ path: currentActiveFile.path,
+ active: false,
+ });
+ }
+
+ commit(types.SET_FILE_ACTIVE, { path, active: true });
+ dispatch('scrollToTab');
+
+ commit(types.SET_CURRENT_PROJECT, file.projectId);
+ commit(types.SET_CURRENT_BRANCH, file.branchId);
+};
+
+export const getFileData = ({ state, commit, dispatch }, file) => {
+ commit(types.TOGGLE_LOADING, { entry: file });
+
+ return service
+ .getFileData(file.url)
+ .then(res => {
+ const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
+
+ setPageTitle(pageTitle);
+
+ return res.json();
+ })
+ .then(data => {
+ commit(types.SET_FILE_DATA, { data, file });
+ commit(types.TOGGLE_FILE_OPEN, file.path);
+ dispatch('setFileActive', file.path);
+ commit(types.TOGGLE_LOADING, { entry: file });
+ })
+ .catch(() => {
+ commit(types.TOGGLE_LOADING, { entry: file });
+ flash(
+ 'Error loading file data. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ });
+};
+
+export const getRawFileData = ({ commit, dispatch }, file) =>
+ service
+ .getRawFileData(file)
+ .then(raw => {
+ commit(types.SET_FILE_RAW_DATA, { file, raw });
+ })
+ .catch(() =>
+ flash(
+ 'Error loading file content. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ ),
+ );
+
+export const changeFileContent = ({ state, commit }, { path, content }) => {
+ const file = state.entries[path];
+ commit(types.UPDATE_FILE_CONTENT, { path, content });
+
+ const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path);
+
+ if (file.changed && indexOfChangedFile === -1) {
+ commit(types.ADD_FILE_TO_CHANGED, path);
+ } else if (!file.changed && indexOfChangedFile !== -1) {
+ commit(types.REMOVE_FILE_FROM_CHANGED, path);
+ }
+};
+
+export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_LANGUAGE, { file: getters.activeFile, fileLanguage });
+ }
+};
+
+export const setFileEOL = ({ getters, commit }, { eol }) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_EOL, { file: getters.activeFile, eol });
+ }
+};
+
+export const setEditorPosition = (
+ { getters, commit },
+ { editorRow, editorColumn },
+) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_POSITION, {
+ file: getters.activeFile,
+ editorRow,
+ editorColumn,
+ });
+ }
+};
+
+export const discardFileChanges = ({ state, commit }, path) => {
+ const file = state.entries[path];
+
+ commit(types.DISCARD_FILE_CHANGES, path);
+ commit(types.REMOVE_FILE_FROM_CHANGED, path);
+
+ if (file.tempFile && file.opened) {
+ commit(types.TOGGLE_FILE_OPEN, path);
+ }
+
+ eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw);
+};
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
new file mode 100644
index 00000000000..b3882cb8d21
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -0,0 +1,49 @@
+import flash from '~/flash';
+import service from '../../services';
+import * as types from '../mutation_types';
+
+export const getProjectData = (
+ { commit, state, dispatch },
+ { namespace, projectId, force = false } = {},
+) => new Promise((resolve, reject) => {
+ if (!state.projects[`${namespace}/${projectId}`] || force) {
+ commit(types.TOGGLE_LOADING, { entry: state });
+ service.getProjectData(namespace, projectId)
+ .then(res => res.data)
+ .then((data) => {
+ commit(types.TOGGLE_LOADING, { entry: state });
+ commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data });
+ if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`);
+ resolve(data);
+ })
+ .catch(() => {
+ flash('Error loading project data. Please try again.', 'alert', document, null, false, true);
+ reject(new Error(`Project not loaded ${namespace}/${projectId}`));
+ });
+ } else {
+ resolve(state.projects[`${namespace}/${projectId}`]);
+ }
+});
+
+export const getBranchData = (
+ { commit, state, dispatch },
+ { projectId, branchId, force = false } = {},
+) => new Promise((resolve, reject) => {
+ if ((typeof state.projects[`${projectId}`] === 'undefined' ||
+ !state.projects[`${projectId}`].branches[branchId])
+ || force) {
+ service.getBranchData(`${projectId}`, branchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data });
+ commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
+ resolve(data);
+ })
+ .catch(() => {
+ flash('Error loading branch data. Please try again.', 'alert', document, null, false, true);
+ reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
+ });
+ } else {
+ resolve(state.projects[`${projectId}`].branches[branchId]);
+ }
+});
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
new file mode 100644
index 00000000000..70a969a0325
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -0,0 +1,93 @@
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import flash from '~/flash';
+import service from '../../services';
+import * as types from '../mutation_types';
+import {
+ findEntry,
+} from '../utils';
+import FilesDecoratorWorker from '../workers/files_decorator_worker';
+
+export const toggleTreeOpen = ({ commit, dispatch }, path) => {
+ commit(types.TOGGLE_TREE_OPEN, path);
+};
+
+export const handleTreeEntryAction = ({ commit, dispatch }, row) => {
+ if (row.type === 'tree') {
+ dispatch('toggleTreeOpen', row.path);
+ } else if (row.type === 'blob' && (row.opened || row.changed)) {
+ if (row.changed && !row.opened) {
+ commit(types.TOGGLE_FILE_OPEN, row.path);
+ }
+
+ dispatch('setFileActive', row.path);
+ } else {
+ dispatch('getFileData', row);
+ }
+};
+
+export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => {
+ if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return;
+
+ service.getTreeLastCommit(tree.lastCommitPath)
+ .then((res) => {
+ const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null;
+
+ commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath });
+
+ return res.json();
+ })
+ .then((data) => {
+ data.forEach((lastCommit) => {
+ const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name);
+
+ if (entry) {
+ commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit });
+ }
+ });
+
+ dispatch('getLastCommitData', tree);
+ })
+ .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true));
+};
+
+export const getFiles = (
+ { state, commit, dispatch },
+ { projectId, branchId } = {},
+) => new Promise((resolve, reject) => {
+ if (!state.trees[`${projectId}/${branchId}`]) {
+ const selectedProject = state.projects[projectId];
+ commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` });
+
+ service
+ .getFiles(selectedProject.web_url, branchId)
+ .then(res => res.json())
+ .then((data) => {
+ const worker = new FilesDecoratorWorker();
+ worker.addEventListener('message', (e) => {
+ const { entries, treeList } = e.data;
+ const selectedTree = state.trees[`${projectId}/${branchId}`];
+
+ commit(types.SET_ENTRIES, entries);
+ commit(types.SET_DIRECTORY_DATA, { treePath: `${projectId}/${branchId}`, data: treeList });
+ commit(types.TOGGLE_LOADING, { entry: selectedTree, forceValue: false });
+
+ worker.terminate();
+
+ resolve();
+ });
+
+ worker.postMessage({
+ data,
+ projectId,
+ branchId,
+ });
+ })
+ .catch((e) => {
+ flash('Error loading tree data. Please try again.', 'alert', document, null, false, true);
+ reject(e);
+ });
+ } else {
+ resolve();
+ }
+});
+
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
new file mode 100644
index 00000000000..eba325a31df
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -0,0 +1,30 @@
+export const activeFile = state =>
+ state.openFiles.find(file => file.active) || null;
+
+export const addedFiles = state => state.changedFiles.filter(f => f.tempFile);
+
+export const modifiedFiles = state =>
+ state.changedFiles.filter(f => !f.tempFile);
+
+export const projectsWithTrees = state =>
+ Object.keys(state.projects).map(projectId => {
+ const project = state.projects[projectId];
+
+ return {
+ ...project,
+ branches: Object.keys(project.branches).map(branchId => {
+ const branch = project.branches[branchId];
+
+ return {
+ ...branch,
+ tree: state.trees[branch.treeId],
+ };
+ }),
+ };
+ });
+
+// eslint-disable-next-line no-confusing-arrow
+export const currentIcon = state =>
+ state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
+
+export const hasChanges = state => !!state.changedFiles.length;
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
new file mode 100644
index 00000000000..7c82ce7976b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import commitModule from './modules/commit';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: state(),
+ actions,
+ mutations,
+ getters,
+ modules: {
+ commit: commitModule,
+ },
+});
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
new file mode 100644
index 00000000000..2e1aea9a399
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -0,0 +1,218 @@
+import $ from 'jquery';
+import { sprintf, __ } from '~/locale';
+import flash from '~/flash';
+import { stripHtml } from '~/lib/utils/text_utility';
+import * as rootTypes from '../../mutation_types';
+import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
+import router from '../../../ide_router';
+import service from '../../../services';
+import * as types from './mutation_types';
+import * as consts from './constants';
+import eventHub from '../../../eventhub';
+
+export const updateCommitMessage = ({ commit }, message) => {
+ commit(types.UPDATE_COMMIT_MESSAGE, message);
+};
+
+export const discardDraft = ({ commit }) => {
+ commit(types.UPDATE_COMMIT_MESSAGE, '');
+};
+
+export const updateCommitAction = ({ commit }, commitAction) => {
+ commit(types.UPDATE_COMMIT_ACTION, commitAction);
+};
+
+export const updateBranchName = ({ commit }, branchName) => {
+ commit(types.UPDATE_NEW_BRANCH_NAME, branchName);
+};
+
+export const setLastCommitMessage = ({ rootState, commit }, data) => {
+ const currentProject = rootState.projects[rootState.currentProjectId];
+ const commitStats = data.stats
+ ? sprintf(__('with %{additions} additions, %{deletions} deletions.'), {
+ additions: data.stats.additions,
+ deletions: data.stats.deletions,
+ })
+ : '';
+ const commitMsg = sprintf(
+ __('Your changes have been committed. Commit %{commitId} %{commitStats}'),
+ {
+ commitId: `<a href="${currentProject.web_url}/commit/${
+ data.short_id
+ }" class="commit-sha">${data.short_id}</a>`,
+ commitStats,
+ },
+ false,
+ );
+
+ commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true });
+};
+
+export const checkCommitStatus = ({ rootState }) =>
+ service
+ .getBranchData(rootState.currentProjectId, rootState.currentBranchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ const selectedBranch =
+ rootState.projects[rootState.currentProjectId].branches[
+ rootState.currentBranchId
+ ];
+
+ if (selectedBranch.workingReference !== id) {
+ return true;
+ }
+
+ return false;
+ })
+ .catch(() =>
+ flash(
+ __('Error checking branch data. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ ),
+ );
+
+export const updateFilesAfterCommit = (
+ { commit, dispatch, state, rootState, rootGetters },
+ { data, branch },
+) => {
+ const selectedProject = rootState.projects[rootState.currentProjectId];
+ const lastCommit = {
+ commit_path: `${selectedProject.web_url}/commit/${data.id}`,
+ commit: {
+ id: data.id,
+ message: data.message,
+ authored_date: data.committed_date,
+ author_name: data.committer_name,
+ },
+ };
+
+ commit(
+ rootTypes.SET_BRANCH_WORKING_REFERENCE,
+ {
+ projectId: rootState.currentProjectId,
+ branchId: rootState.currentBranchId,
+ reference: data.id,
+ },
+ { root: true },
+ );
+
+ rootState.changedFiles.forEach(entry => {
+ commit(
+ rootTypes.SET_LAST_COMMIT_DATA,
+ {
+ entry,
+ lastCommit,
+ },
+ { root: true },
+ );
+
+ eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content);
+
+ commit(
+ rootTypes.SET_FILE_RAW_DATA,
+ {
+ file: entry,
+ raw: entry.content,
+ },
+ { root: true },
+ );
+
+ commit(
+ rootTypes.TOGGLE_FILE_CHANGED,
+ {
+ file: entry,
+ changed: false,
+ },
+ { root: true },
+ );
+ });
+
+ commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true });
+
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
+ router.push(
+ `/project/${rootState.currentProjectId}/blob/${branch}/${
+ rootGetters.activeFile.path
+ }`,
+ );
+ }
+
+ dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH);
+};
+
+export const commitChanges = ({
+ commit,
+ state,
+ getters,
+ dispatch,
+ rootState,
+}) => {
+ const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
+ const payload = createCommitPayload(
+ getters.branchName,
+ newBranch,
+ state,
+ rootState,
+ );
+ const getCommitStatus = newBranch
+ ? Promise.resolve(false)
+ : dispatch('checkCommitStatus');
+
+ commit(types.UPDATE_LOADING, true);
+
+ return getCommitStatus
+ .then(
+ branchChanged =>
+ new Promise(resolve => {
+ if (branchChanged) {
+ // show the modal with a Bootstrap call
+ $('#ide-create-branch-modal').modal('show');
+ } else {
+ resolve();
+ }
+ }),
+ )
+ .then(() => service.commit(rootState.currentProjectId, payload))
+ .then(({ data }) => {
+ commit(types.UPDATE_LOADING, false);
+
+ if (!data.short_id) {
+ flash(data.message, 'alert', document, null, false, true);
+ return;
+ }
+
+ dispatch('setLastCommitMessage', data);
+ dispatch('updateCommitMessage', '');
+
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) {
+ dispatch(
+ 'redirectToUrl',
+ createNewMergeRequestUrl(
+ rootState.projects[rootState.currentProjectId].web_url,
+ getters.branchName,
+ rootState.currentBranchId,
+ ),
+ { root: true },
+ );
+ } else {
+ dispatch('updateFilesAfterCommit', {
+ data,
+ branch: getters.branchName,
+ });
+ }
+ })
+ .catch(err => {
+ let errMsg = __('Error committing changes. Please try again.');
+ if (err.response.data && err.response.data.message) {
+ errMsg += ` (${stripHtml(err.response.data.message)})`;
+ }
+ flash(errMsg, 'alert', document, null, false, true);
+ window.dispatchEvent(new Event('resize'));
+
+ commit(types.UPDATE_LOADING, false);
+ });
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js
new file mode 100644
index 00000000000..230b0a3d9b5
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js
@@ -0,0 +1,3 @@
+export const COMMIT_TO_CURRENT_BRANCH = '1';
+export const COMMIT_TO_NEW_BRANCH = '2';
+export const COMMIT_TO_NEW_BRANCH_MR = '3';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
new file mode 100644
index 00000000000..f7cdd6adb0c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -0,0 +1,24 @@
+import * as consts from './constants';
+
+export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading;
+
+export const commitButtonDisabled = (state, getters, rootState) =>
+ getters.discardDraftButtonDisabled || !rootState.changedFiles.length;
+
+export const newBranchName = (state, _, rootState) =>
+ `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`;
+
+export const branchName = (state, getters, rootState) => {
+ if (
+ state.commitAction === consts.COMMIT_TO_NEW_BRANCH ||
+ state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR
+ ) {
+ if (state.newBranchName === '') {
+ return getters.newBranchName;
+ }
+
+ return state.newBranchName;
+ }
+
+ return rootState.currentBranchId;
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/index.js b/app/assets/javascripts/ide/stores/modules/commit/index.js
new file mode 100644
index 00000000000..3bf65b02847
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/index.js
@@ -0,0 +1,12 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+import * as getters from './getters';
+
+export default {
+ namespaced: true,
+ state: state(),
+ mutations,
+ actions,
+ getters,
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
new file mode 100644
index 00000000000..9221f054e9f
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
@@ -0,0 +1,4 @@
+export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE';
+export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION';
+export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME';
+export const UPDATE_LOADING = 'UPDATE_LOADING';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
new file mode 100644
index 00000000000..797357e3df9
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
@@ -0,0 +1,24 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.UPDATE_COMMIT_MESSAGE](state, commitMessage) {
+ Object.assign(state, {
+ commitMessage,
+ });
+ },
+ [types.UPDATE_COMMIT_ACTION](state, commitAction) {
+ Object.assign(state, {
+ commitAction,
+ });
+ },
+ [types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) {
+ Object.assign(state, {
+ newBranchName,
+ });
+ },
+ [types.UPDATE_LOADING](state, submitCommitLoading) {
+ Object.assign(state, {
+ submitCommitLoading,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js
new file mode 100644
index 00000000000..8dae50961b0
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/state.js
@@ -0,0 +1,6 @@
+export default () => ({
+ commitMessage: '',
+ commitAction: '1',
+ newBranchName: '',
+ submitCommitLoading: false,
+});
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
new file mode 100644
index 00000000000..e28f190897c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -0,0 +1,43 @@
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
+export const TOGGLE_LOADING = 'TOGGLE_LOADING';
+export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
+export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG';
+export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
+export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
+export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
+
+// Project Mutation Types
+export const SET_PROJECT = 'SET_PROJECT';
+export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
+export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN';
+
+// Branch Mutation Types
+export const SET_BRANCH = 'SET_BRANCH';
+export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE';
+export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN';
+
+// Tree mutation types
+export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
+export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN';
+export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL';
+export const CREATE_TREE = 'CREATE_TREE';
+export const REMOVE_ALL_CHANGES_FILES = 'REMOVE_ALL_CHANGES_FILES';
+
+// File mutation types
+export const SET_FILE_DATA = 'SET_FILE_DATA';
+export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN';
+export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE';
+export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA';
+export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
+export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
+export const SET_FILE_POSITION = 'SET_FILE_POSITION';
+export const SET_FILE_EOL = 'SET_FILE_EOL';
+export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
+export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
+export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED';
+export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED';
+export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH';
+export const SET_ENTRIES = 'SET_ENTRIES';
+export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY';
+export const UPDATE_VIEWER = 'UPDATE_VIEWER';
+export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
new file mode 100644
index 00000000000..da41fc9285c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -0,0 +1,106 @@
+import * as types from './mutation_types';
+import projectMutations from './mutations/project';
+import fileMutations from './mutations/file';
+import treeMutations from './mutations/tree';
+import branchMutations from './mutations/branch';
+
+export default {
+ [types.SET_INITIAL_DATA](state, data) {
+ Object.assign(state, data);
+ },
+ [types.TOGGLE_LOADING](state, { entry, forceValue = undefined }) {
+ if (entry.path) {
+ Object.assign(state.entries[entry.path], {
+ loading:
+ forceValue !== undefined
+ ? forceValue
+ : !state.entries[entry.path].loading,
+ });
+ } else {
+ Object.assign(entry, {
+ loading: forceValue !== undefined ? forceValue : !entry.loading,
+ });
+ }
+ },
+ [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) {
+ Object.assign(state, {
+ leftPanelCollapsed: collapsed,
+ });
+ },
+ [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) {
+ Object.assign(state, {
+ rightPanelCollapsed: collapsed,
+ });
+ },
+ [types.SET_RESIZING_STATUS](state, resizing) {
+ Object.assign(state, {
+ panelResizing: resizing,
+ });
+ },
+ [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) {
+ Object.assign(entry.lastCommit, {
+ id: lastCommit.commit.id,
+ url: lastCommit.commit_path,
+ message: lastCommit.commit.message,
+ author: lastCommit.commit.author_name,
+ updatedAt: lastCommit.commit.authored_date,
+ });
+ },
+ [types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) {
+ Object.assign(state, {
+ lastCommitMsg,
+ });
+ },
+ [types.SET_ENTRIES](state, entries) {
+ Object.assign(state, {
+ entries,
+ });
+ },
+ [types.CREATE_TMP_ENTRY](state, { data, projectId, branchId }) {
+ Object.keys(data.entries).reduce((acc, key) => {
+ const entry = data.entries[key];
+ const foundEntry = state.entries[key];
+
+ if (!foundEntry) {
+ Object.assign(state.entries, {
+ [key]: entry,
+ });
+ } else {
+ const tree = entry.tree.filter(
+ f => foundEntry.tree.find(e => e.path === f.path) === undefined,
+ );
+ Object.assign(foundEntry, {
+ tree: foundEntry.tree.concat(tree),
+ });
+ }
+
+ return acc.concat(key);
+ }, []);
+
+ const foundEntry = state.trees[`${projectId}/${branchId}`].tree.find(
+ e => e.path === data.treeList[0].path,
+ );
+
+ if (!foundEntry) {
+ Object.assign(state.trees[`${projectId}/${branchId}`], {
+ tree: state.trees[`${projectId}/${branchId}`].tree.concat(
+ data.treeList,
+ ),
+ });
+ }
+ },
+ [types.UPDATE_VIEWER](state, viewer) {
+ Object.assign(state, {
+ viewer,
+ });
+ },
+ [types.UPDATE_DELAY_VIEWER_CHANGE](state, delayViewerUpdated) {
+ Object.assign(state, {
+ delayViewerUpdated,
+ });
+ },
+ ...projectMutations,
+ ...fileMutations,
+ ...treeMutations,
+ ...branchMutations,
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js
new file mode 100644
index 00000000000..2972ba5e38e
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/branch.js
@@ -0,0 +1,26 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.SET_CURRENT_BRANCH](state, currentBranchId) {
+ Object.assign(state, {
+ currentBranchId,
+ });
+ },
+ [types.SET_BRANCH](state, { projectPath, branchName, branch }) {
+ Object.assign(state.projects[projectPath], {
+ branches: {
+ [branchName]: {
+ ...branch,
+ treeId: `${projectPath}/${branchName}`,
+ active: true,
+ workingReference: '',
+ },
+ },
+ });
+ },
+ [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) {
+ Object.assign(state.projects[projectId].branches[branchId], {
+ workingReference: reference,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
new file mode 100644
index 00000000000..2500f13db7c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -0,0 +1,83 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.SET_FILE_ACTIVE](state, { path, active }) {
+ Object.assign(state.entries[path], {
+ active,
+ });
+ },
+ [types.TOGGLE_FILE_OPEN](state, path) {
+ Object.assign(state.entries[path], {
+ opened: !state.entries[path].opened,
+ });
+
+ if (state.entries[path].opened) {
+ state.openFiles.push(state.entries[path]);
+ } else {
+ Object.assign(state, {
+ openFiles: state.openFiles.filter(f => f.path !== path),
+ });
+ }
+ },
+ [types.SET_FILE_DATA](state, { data, file }) {
+ Object.assign(state.entries[file.path], {
+ id: data.id,
+ blamePath: data.blame_path,
+ commitsPath: data.commits_path,
+ permalink: data.permalink,
+ rawPath: data.raw_path,
+ binary: data.binary,
+ renderError: data.render_error,
+ });
+ },
+ [types.SET_FILE_RAW_DATA](state, { file, raw }) {
+ Object.assign(state.entries[file.path], {
+ raw,
+ });
+ },
+ [types.UPDATE_FILE_CONTENT](state, { path, content }) {
+ const changed = content !== state.entries[path].raw;
+
+ Object.assign(state.entries[path], {
+ content,
+ changed,
+ });
+ },
+ [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) {
+ Object.assign(state.entries[file.path], {
+ fileLanguage,
+ });
+ },
+ [types.SET_FILE_EOL](state, { file, eol }) {
+ Object.assign(state.entries[file.path], {
+ eol,
+ });
+ },
+ [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) {
+ Object.assign(state.entries[file.path], {
+ editorRow,
+ editorColumn,
+ });
+ },
+ [types.DISCARD_FILE_CHANGES](state, path) {
+ Object.assign(state.entries[path], {
+ content: state.entries[path].raw,
+ changed: false,
+ });
+ },
+ [types.ADD_FILE_TO_CHANGED](state, path) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.concat(state.entries[path]),
+ });
+ },
+ [types.REMOVE_FILE_FROM_CHANGED](state, path) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.filter(f => f.path !== path),
+ });
+ },
+ [types.TOGGLE_FILE_CHANGED](state, { file, changed }) {
+ Object.assign(state.entries[file.path], {
+ changed,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js
new file mode 100644
index 00000000000..2816562a919
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/project.js
@@ -0,0 +1,23 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.SET_CURRENT_PROJECT](state, currentProjectId) {
+ Object.assign(state, {
+ currentProjectId,
+ });
+ },
+ [types.SET_PROJECT](state, { projectPath, project }) {
+ // Add client side properties
+ Object.assign(project, {
+ tree: [],
+ branches: {},
+ active: true,
+ });
+
+ Object.assign(state, {
+ projects: Object.assign({}, state.projects, {
+ [projectPath]: project,
+ }),
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
new file mode 100644
index 00000000000..7f7e470c9bb
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -0,0 +1,38 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.TOGGLE_TREE_OPEN](state, path) {
+ Object.assign(state.entries[path], {
+ opened: !state.entries[path].opened,
+ });
+ },
+ [types.CREATE_TREE](state, { treePath }) {
+ Object.assign(state, {
+ trees: Object.assign({}, state.trees, {
+ [treePath]: {
+ tree: [],
+ loading: true,
+ },
+ }),
+ });
+ },
+ [types.SET_DIRECTORY_DATA](state, { data, treePath }) {
+ Object.assign(state, {
+ trees: Object.assign(state.trees, {
+ [treePath]: {
+ tree: data,
+ },
+ }),
+ });
+ },
+ [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) {
+ Object.assign(tree, {
+ lastCommitPath: url,
+ });
+ },
+ [types.REMOVE_ALL_CHANGES_FILES](state) {
+ Object.assign(state, {
+ changedFiles: [],
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
new file mode 100644
index 00000000000..6110f54951c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -0,0 +1,19 @@
+export default () => ({
+ currentProjectId: '',
+ currentBranchId: '',
+ changedFiles: [],
+ endpoints: {},
+ lastCommitMsg: '',
+ lastCommitPath: '',
+ loading: false,
+ openFiles: [],
+ parentTreeUrl: '',
+ trees: {},
+ projects: {},
+ leftPanelCollapsed: false,
+ rightPanelCollapsed: false,
+ panelResizing: false,
+ entries: {},
+ viewer: 'editor',
+ delayViewerUpdated: false,
+});
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
new file mode 100644
index 00000000000..487ea1ead8e
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -0,0 +1,125 @@
+export const dataStructure = () => ({
+ id: '',
+ key: '',
+ type: '',
+ projectId: '',
+ branchId: '',
+ name: '',
+ url: '',
+ path: '',
+ tempFile: false,
+ tree: [],
+ loading: false,
+ opened: false,
+ active: false,
+ changed: false,
+ lastCommitPath: '',
+ lastCommit: {
+ id: '',
+ url: '',
+ message: '',
+ updatedAt: '',
+ author: '',
+ },
+ blamePath: '',
+ commitsPath: '',
+ permalink: '',
+ rawPath: '',
+ binary: false,
+ html: '',
+ raw: '',
+ content: '',
+ parentTreeUrl: '',
+ renderError: false,
+ base64: false,
+ editorRow: 1,
+ editorColumn: 1,
+ fileLanguage: '',
+ eol: '',
+});
+
+export const decorateData = (entity) => {
+ const {
+ id,
+ projectId,
+ branchId,
+ type,
+ url,
+ name,
+ path,
+ renderError,
+ content = '',
+ tempFile = false,
+ active = false,
+ opened = false,
+ changed = false,
+ parentTreeUrl = '',
+ base64 = false,
+
+ file_lock,
+
+ } = entity;
+
+ return {
+ ...dataStructure(),
+ id,
+ projectId,
+ branchId,
+ key: `${name}-${type}-${id}`,
+ type,
+ name,
+ url,
+ path,
+ tempFile,
+ opened,
+ active,
+ parentTreeUrl,
+ changed,
+ renderError,
+ content,
+ base64,
+
+ file_lock,
+
+ };
+};
+
+export const findEntry = (tree, type, name, prop = 'name') => tree.find(
+ f => f.type === type && f[prop] === name,
+);
+
+export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path);
+
+export const setPageTitle = (title) => {
+ document.title = title;
+};
+
+export const createCommitPayload = (branch, newBranch, state, rootState) => ({
+ branch,
+ commit_message: state.commitMessage,
+ actions: rootState.changedFiles.map(f => ({
+ action: f.tempFile ? 'create' : 'update',
+ file_path: f.path,
+ content: f.content,
+ encoding: f.base64 ? 'base64' : 'text',
+ })),
+ start_branch: newBranch ? rootState.currentBranchId : undefined,
+});
+
+export const createNewMergeRequestUrl = (projectUrl, source, target) =>
+ `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}`;
+
+const sortTreesByTypeAndName = (a, b) => {
+ if (a.type === 'tree' && b.type === 'blob') {
+ return -1;
+ } else if (a.type === 'blob' && b.type === 'tree') {
+ return 1;
+ }
+ if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
+ if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
+ return 0;
+};
+
+export const sortTree = sortedTree => sortedTree.map(entity => Object.assign(entity, {
+ tree: entity.tree.length ? sortTree(entity.tree) : [],
+})).sort(sortTreesByTypeAndName);
diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
new file mode 100644
index 00000000000..e959130300b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
@@ -0,0 +1,91 @@
+import {
+ decorateData,
+ sortTree,
+} from '../utils';
+
+self.addEventListener('message', (e) => {
+ const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data;
+
+ const treeList = [];
+ let file;
+ const entries = data.reduce((acc, path) => {
+ const pathSplit = path.split('/');
+ const blobName = pathSplit.pop().trim();
+
+ if (pathSplit.length > 0) {
+ pathSplit.reduce((pathAcc, folderName) => {
+ const parentFolder = acc[pathAcc[pathAcc.length - 1]];
+ const folderPath = `${(parentFolder ? `${parentFolder.path}/` : '')}${folderName}`;
+ const foundEntry = acc[folderPath];
+
+ if (!foundEntry) {
+ const tree = decorateData({
+ projectId,
+ branchId,
+ id: folderPath,
+ name: folderName,
+ path: folderPath,
+ url: `/${projectId}/tree/${branchId}/${folderPath}`,
+ type: 'tree',
+ parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
+ tempFile,
+ changed: tempFile,
+ opened: tempFile,
+ });
+
+ Object.assign(acc, {
+ [folderPath]: tree,
+ });
+
+ if (parentFolder) {
+ parentFolder.tree.push(tree);
+ } else {
+ treeList.push(tree);
+ }
+
+ pathAcc.push(tree.path);
+ } else {
+ pathAcc.push(foundEntry.path);
+ }
+
+ return pathAcc;
+ }, []);
+ }
+
+ if (blobName !== '') {
+ const fileFolder = acc[pathSplit.join('/')];
+ file = decorateData({
+ projectId,
+ branchId,
+ id: path,
+ name: blobName,
+ path,
+ url: `/${projectId}/blob/${branchId}/${path}`,
+ type: 'blob',
+ parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
+ tempFile,
+ changed: tempFile,
+ content,
+ base64,
+ });
+
+ Object.assign(acc, {
+ [path]: file,
+ });
+
+ if (fileFolder) {
+ fileFolder.tree.push(file);
+ } else {
+ treeList.push(file);
+ }
+ }
+
+ return acc;
+ }, {});
+
+ self.postMessage({
+ entries,
+ treeList: sortTree(treeList),
+ file,
+ });
+});
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index 2d015ef086b..93cb83b3a4c 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -20,7 +20,7 @@
width: 100%;
}
- $image-widths: 250 306 394 430;
+ $image-widths: 80 250 306 394 430;
@each $width in $image-widths {
&.svg-#{$width} {
img,
@@ -39,12 +39,28 @@
svg {
fill: currentColor;
- &.s8 { @include svg-size(8px); }
- &.s12 { @include svg-size(12px); }
- &.s16 { @include svg-size(16px); }
- &.s18 { @include svg-size(18px); }
- &.s24 { @include svg-size(24px); }
- &.s32 { @include svg-size(32px); }
- &.s48 { @include svg-size(48px); }
- &.s72 { @include svg-size(72px); }
+ &.s8 {
+ @include svg-size(8px);
+ }
+ &.s12 {
+ @include svg-size(12px);
+ }
+ &.s16 {
+ @include svg-size(16px);
+ }
+ &.s18 {
+ @include svg-size(18px);
+ }
+ &.s24 {
+ @include svg-size(24px);
+ }
+ &.s32 {
+ @include svg-size(32px);
+ }
+ &.s48 {
+ @include svg-size(48px);
+ }
+ &.s72 {
+ @include svg-size(72px);
+ }
}
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index 8265b8370f7..7a8fbfc517d 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -19,6 +19,7 @@
.ide-view {
display: flex;
height: calc(100vh - #{$header-height});
+ margin-top: 40px;
color: $almost-black;
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
@@ -28,6 +29,11 @@
max-width: 250px;
}
}
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ }
}
.ide-file-list {
@@ -40,31 +46,41 @@
background: $white-normal;
}
- .repo-file-name {
+ .ide-file-name {
+ flex: 1;
white-space: nowrap;
text-overflow: ellipsis;
+
+ svg {
+ vertical-align: middle;
+ margin-right: 2px;
+ }
+
+ .loading-container {
+ margin-right: 4px;
+ display: inline-block;
+ }
}
- .unsaved-icon {
- color: $indigo-700;
- float: right;
- font-size: smaller;
- line-height: 20px;
+ .ide-file-changed-icon {
+ margin-left: auto;
}
- .repo-new-btn {
+ .ide-new-btn {
display: none;
- margin-top: -4px;
margin-bottom: -4px;
+ margin-right: -8px;
}
&:hover {
- .repo-new-btn {
+ .ide-new-btn {
display: block;
}
+ }
- .unsaved-icon {
- display: none;
+ &.folder {
+ svg {
+ fill: $gl-text-color-secondary;
}
}
}
@@ -79,10 +95,10 @@
}
}
-.multi-file-table-name,
-.multi-file-table-col-commit-message {
+.file-name,
+.file-col-commit-message {
+ display: flex;
overflow: visible;
- max-width: 0;
padding: 6px 12px;
}
@@ -99,21 +115,6 @@
}
}
-table.table tr td.multi-file-table-name {
- width: 350px;
- padding: 6px 12px;
-
- svg {
- vertical-align: middle;
- margin-right: 2px;
- }
-
- .loading-container {
- margin-right: 4px;
- display: inline-block;
- }
-}
-
.multi-file-table-col-commit-message {
white-space: nowrap;
width: 50%;
@@ -129,13 +130,35 @@ table.table tr td.multi-file-table-name {
.multi-file-tabs {
display: flex;
- overflow-x: auto;
background-color: $white-normal;
box-shadow: inset 0 -1px $white-dark;
- > li {
+ > ul {
+ display: flex;
+ overflow-x: auto;
+ }
+
+ li {
position: relative;
}
+
+ .dropdown {
+ display: flex;
+ margin-left: auto;
+ margin-bottom: 1px;
+ padding: 0 $grid-size;
+ border-left: 1px solid $white-dark;
+ background-color: $white-light;
+
+ &.shadow {
+ box-shadow: 0 0 10px $dropdown-shadow-color;
+ }
+
+ .btn {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
}
.multi-file-tab {
@@ -160,20 +183,32 @@ table.table tr td.multi-file-table-name {
position: absolute;
right: 8px;
top: 50%;
+ width: 16px;
+ height: 16px;
padding: 0;
background: none;
border: 0;
- font-size: $gl-font-size;
- color: $gray-darkest;
+ border-radius: $border-radius-default;
+ color: $theme-gray-900;
transform: translateY(-50%);
- &:not(.modified):hover,
- &:not(.modified):focus {
- color: $hint-color;
+ svg {
+ position: relative;
+ top: -1px;
}
- &.modified {
- color: $indigo-700;
+ &:hover {
+ background-color: $theme-gray-200;
+ }
+
+ &:focus {
+ background-color: $blue-500;
+ color: $white-light;
+ outline: 0;
+
+ svg {
+ fill: currentColor;
+ }
}
}
@@ -192,6 +227,70 @@ table.table tr td.multi-file-table-name {
.vertical-center {
min-height: auto;
}
+
+ .monaco-editor .lines-content .cigr {
+ display: none;
+ }
+
+ .monaco-diff-editor.vs {
+ .editor.modified {
+ box-shadow: none;
+ }
+
+ .diagonal-fill {
+ display: none !important;
+ }
+
+ .diffOverview {
+ background-color: $white-light;
+ border-left: 1px solid $white-dark;
+ cursor: ns-resize;
+ }
+
+ .diffViewport {
+ display: none;
+ }
+
+ .char-insert {
+ background-color: $line-added-dark;
+ }
+
+ .char-delete {
+ background-color: $line-removed-dark;
+ }
+
+ .line-numbers {
+ color: $black-transparent;
+ }
+
+ .view-overlays {
+ .line-insert {
+ background-color: $line-added;
+ }
+
+ .line-delete {
+ background-color: $line-removed;
+ }
+ }
+
+ .margin {
+ background-color: $gray-light;
+ border-right: 1px solid $white-normal;
+
+ .line-insert {
+ border-right: 1px solid $line-added-dark;
+ }
+
+ .line-delete {
+ border-right: 1px solid $line-removed-dark;
+ }
+ }
+
+ .margin-view-overlays .insert-sign,
+ .margin-view-overlays .delete-sign {
+ opacity: 0.4;
+ }
+ }
}
.multi-file-editor-holder {
@@ -252,7 +351,7 @@ table.table tr td.multi-file-table-name {
display: flex;
position: relative;
flex-direction: column;
- width: 290px;
+ width: 340px;
padding: 0;
background-color: $gray-light;
padding-right: 3px;
@@ -350,6 +449,11 @@ table.table tr td.multi-file-table-name {
flex: 1;
}
+.multi-file-commit-empty-state-container {
+ align-items: center;
+ justify-content: center;
+}
+
.multi-file-commit-panel-header {
display: flex;
align-items: center;
@@ -376,7 +480,7 @@ table.table tr td.multi-file-table-name {
.multi-file-commit-panel-header-title {
display: flex;
flex: 1;
- padding: $gl-btn-padding;
+ padding: 0 $gl-btn-padding;
svg {
margin-right: $gl-btn-padding;
@@ -390,12 +494,34 @@ table.table tr td.multi-file-table-name {
.multi-file-commit-list {
flex: 1;
overflow: auto;
- padding: $gl-padding;
+ padding: $gl-padding 0;
+ min-height: 60px;
}
.multi-file-commit-list-item {
display: flex;
+ padding: 0;
align-items: center;
+
+ .multi-file-discard-btn {
+ display: none;
+ margin-left: auto;
+ color: $gl-link-color;
+ padding: 0 2px;
+
+ &:focus,
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ &:hover {
+ background: $white-normal;
+
+ .multi-file-discard-btn {
+ display: block;
+ }
+ }
}
.multi-file-addition {
@@ -414,29 +540,58 @@ table.table tr td.multi-file-table-name {
margin-left: auto;
margin-right: auto;
}
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ margin-left: 3px;
+ }
}
.multi-file-commit-list-path {
+ padding: $grid-size / 2;
+ padding-left: $gl-padding;
+ background: none;
+ border: 0;
+ text-align: left;
+ width: 100%;
+ min-width: 0;
+
+ svg {
+ min-width: 16px;
+ vertical-align: middle;
+ display: inline-block;
+ }
+
+ &:hover,
+ &:focus {
+ outline: 0;
+ }
+}
+
+.multi-file-commit-list-file-path {
@include str-truncated(100%);
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ text-decoration: none;
+ }
}
.multi-file-commit-form {
padding: $gl-padding;
border-top: 1px solid $white-dark;
-}
-
-.multi-file-commit-fieldset {
- display: flex;
- align-items: center;
- padding-bottom: 12px;
.btn {
- flex: 1;
+ font-size: $gl-font-size;
}
}
.multi-file-commit-message.form-control {
- height: 80px;
+ height: 160px;
resize: none;
}
@@ -468,7 +623,7 @@ table.table tr td.multi-file-table-name {
top: 0;
width: 100px;
height: 1px;
- background-color: rgba($red-500, .5);
+ background-color: rgba($red-500, 0.5);
}
}
}
@@ -487,7 +642,7 @@ table.table tr td.multi-file-table-name {
justify-content: center;
}
-.repo-new-btn {
+.ide-new-btn {
.dropdown-toggle svg {
margin-top: -2px;
margin-bottom: 2px;
@@ -505,36 +660,39 @@ table.table tr td.multi-file-table-name {
}
}
-.ide.nav-only {
- .flash-container {
- margin-top: $header-height;
- margin-bottom: 0;
- }
-
- .alert-wrapper .flash-container .flash-alert:last-child,
- .alert-wrapper .flash-container .flash-notice:last-child {
- margin-bottom: 0;
- }
+.ide {
+ overflow: hidden;
- .content {
- margin-top: $header-height;
- }
+ &.nav-only {
+ .flash-container {
+ margin-top: $header-height;
+ margin-bottom: 0;
+ }
- .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
- max-height: calc(100vh - #{$header-height + $context-header-height});
- }
+ .alert-wrapper .flash-container .flash-alert:last-child,
+ .alert-wrapper .flash-container .flash-notice:last-child {
+ margin-bottom: 0;
+ }
- &.flash-shown {
- .content {
- margin-top: 0;
+ .content-wrapper {
+ margin-top: $header-height;
+ padding-bottom: 0;
}
- .ide-view {
- height: calc(100vh - #{$header-height + $flash-height});
+ &.flash-shown {
+ .content-wrapper {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $flash-height});
+ }
}
- .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
- max-height: calc(100vh - #{$header-height + $flash-height + $context-header-height});
+ .projects-sidebar {
+ .multi-file-commit-panel-inner-scroll {
+ flex: 1;
+ }
}
}
}
@@ -544,34 +702,28 @@ table.table tr td.multi-file-table-name {
margin-top: #{$header-height + $performance-bar-height};
}
- .content {
+ .content-wrapper {
margin-top: #{$header-height + $performance-bar-height};
+ padding-bottom: 0;
}
.ide-view {
height: calc(100vh - #{$header-height + $performance-bar-height});
}
- .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
- max-height: calc(100vh - #{$header-height + $performance-bar-height + 60});
- }
-
&.flash-shown {
- .content {
+ .content-wrapper {
margin-top: 0;
}
.ide-view {
- height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height});
- }
-
- .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
- max-height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height + $context-header-height});
+ height: calc(
+ 100vh - #{$header-height + $performance-bar-height + $flash-height}
+ );
}
}
}
-
.dragHandle {
position: absolute;
top: 0;
@@ -587,3 +739,44 @@ table.table tr td.multi-file-table-name {
left: 0;
}
}
+
+.ide-commit-radios {
+ label {
+ font-weight: normal;
+ }
+
+ .help-block {
+ margin-top: 0;
+ line-height: 0;
+ }
+}
+
+.ide-commit-new-branch {
+ margin-left: 25px;
+}
+
+.ide-external-links {
+ p {
+ margin: 0;
+ }
+}
+
+.ide-sidebar-link {
+ padding: $gl-padding-8 $gl-padding;
+ background: $indigo-700;
+ color: $white-light;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+
+ &:focus,
+ &:hover {
+ color: $white-light;
+ text-decoration: underline;
+ background: $indigo-500;
+ }
+
+ &:active {
+ background: $indigo-800;
+ }
+}
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
new file mode 100644
index 00000000000..1ff25a45398
--- /dev/null
+++ b/app/controllers/ide_controller.rb
@@ -0,0 +1,6 @@
+class IdeController < ApplicationController
+ layout 'nav_only'
+
+ def index
+ end
+end
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
new file mode 100644
index 00000000000..f090ae71269
--- /dev/null
+++ b/app/helpers/ide_helper.rb
@@ -0,0 +1,14 @@
+module IdeHelper
+ def ide_edit_button(project = @project, ref = @ref, path = @path, options = {})
+ return unless blob = readable_blob(options, path, project, ref)
+
+ common_classes = "btn js-edit-ide #{options[:extra_class]}"
+
+ edit_button_tag(blob,
+ common_classes,
+ _('Web IDE'),
+ ide_edit_path(project, ref, path, options),
+ project,
+ ref)
+ end
+end
diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml
new file mode 100644
index 00000000000..e0e8fe548d0
--- /dev/null
+++ b/app/views/ide/index.html.haml
@@ -0,0 +1,12 @@
+- @body_class = 'ide'
+- page_title 'IDE'
+
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'ide', force_same_domain: true
+
+#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
+ "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
+ "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg') } }
+ .text-center
+ = icon('spinner spin 2x')
+ %h2.clgray= _('Loading the GitLab IDE...')
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 06bce52e709..67613949b7d 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -76,4 +76,8 @@
= render 'projects/find_file_link'
+ = succeed " " do
+ = link_to ide_edit_path(@project, @id), class: 'btn btn-default' do
+ = _('Web IDE')
+
= render 'projects/buttons/download', project: @project, ref: @ref