Commit f527e6e1 authored by Phil Hughes's avatar Phil Hughes

Move IDE to CE

This also makes the IDE generally available
parent 68b914c9
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
import Vue from 'vue';
export default new Vue();
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;
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);
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();
}
}
/* 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,
);
}
}
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();
}
}
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();
}
}
/* 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();
}
}
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;
}, []);
};
import { computeDiff } from './diff';
self.addEventListener('message', (e) => {
const data = e.data;
self.postMessage({
path: data.path,
changes: computeDiff(data.originalContent, data.newContent),
});
});
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)),
);
}
}
export const defaultEditorOptions = {
model: null,
readOnly: false,
contextmenu: true,
scrollBeyondLastLine: false,
minimap: {
enabled: false,
},
};
export default [
{
readOnly: model => !!model.file.file_lock,
},
];
export default {
themeName: 'gitlab',
monacoTheme: {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editorLineNumber.foreground': '#CCCCCC',
'diffEditor.insertedTextBackground': '#ddfbe6',
'diffEditor.removedTextBackground': '#f9d7dc',
'editor.selectionBackground': '#aad6f8',
},
},
};
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;
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',
},
});
},
};
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';
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);
};
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]);
}
});
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();
}
});
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;
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,
},
});
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);
});
};
export const COMMIT_TO_CURRENT_BRANCH = '1';
export const COMMIT_TO_NEW_BRANCH = '2';
export const COMMIT_TO_NEW_BRANCH_MR = '3';
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;
};
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,
};
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';
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,
});
},
};
export default () => ({
commitMessage: '',
commitAction: '1',
newBranchName: '',
submitCommitLoading: false,
});
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';
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,
};
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,
});
},
};
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,
});
},
};
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,
}),
});
},
};
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: [],
});
},
};
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,
});
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);
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,
});
});
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
width: 100%; width: 100%;
} }
$image-widths: 250 306 394 430; $image-widths: 80 250 306 394 430;
@each $width in $image-widths { @each $width in $image-widths {
&.svg-#{$width} { &.svg-#{$width} {
img, img,
...@@ -39,12 +39,28 @@ ...@@ -39,12 +39,28 @@
svg { svg {
fill: currentColor; fill: currentColor;
&.s8 { @include svg-size(8px); } &.s8 {
&.s12 { @include svg-size(12px); } @include svg-size(8px);
&.s16 { @include svg-size(16px); } }
&.s18 { @include svg-size(18px); } &.s12 {
&.s24 { @include svg-size(24px); } @include svg-size(12px);
&.s32 { @include svg-size(32px); } }
&.s48 { @include svg-size(48px); } &.s16 {
&.s72 { @include svg-size(72px); } @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);
}
} }
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
.ide-view { .ide-view {
display: flex; display: flex;
height: calc(100vh - #{$header-height}); height: calc(100vh - #{$header-height});
margin-top: 40px;
color: $almost-black; color: $almost-black;
border-top: 1px solid $white-dark; border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
...@@ -28,6 +29,11 @@ ...@@ -28,6 +29,11 @@
max-width: 250px; max-width: 250px;
} }
} }
.file-status-icon {
width: 10px;
height: 10px;
}
} }
.ide-file-list { .ide-file-list {
...@@ -40,31 +46,41 @@ ...@@ -40,31 +46,41 @@
background: $white-normal; background: $white-normal;
} }
.repo-file-name { .ide-file-name {
flex: 1;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
svg {
vertical-align: middle;
margin-right: 2px;
} }
.unsaved-icon { .loading-container {
color: $indigo-700; margin-right: 4px;
float: right; display: inline-block;
font-size: smaller; }
line-height: 20px;
} }
.repo-new-btn { .ide-file-changed-icon {
margin-left: auto;
}
.ide-new-btn {
display: none; display: none;
margin-top: -4px;
margin-bottom: -4px; margin-bottom: -4px;
margin-right: -8px;
} }
&:hover { &:hover {
.repo-new-btn { .ide-new-btn {
display: block; display: block;
} }
}
.unsaved-icon { &.folder {
display: none; svg {
fill: $gl-text-color-secondary;
} }
} }
} }
...@@ -79,10 +95,10 @@ ...@@ -79,10 +95,10 @@
} }
} }
.multi-file-table-name, .file-name,
.multi-file-table-col-commit-message { .file-col-commit-message {
display: flex;
overflow: visible; overflow: visible;
max-width: 0;
padding: 6px 12px; padding: 6px 12px;
} }
...@@ -99,21 +115,6 @@ ...@@ -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 { .multi-file-table-col-commit-message {
white-space: nowrap; white-space: nowrap;
width: 50%; width: 50%;
...@@ -129,13 +130,35 @@ table.table tr td.multi-file-table-name { ...@@ -129,13 +130,35 @@ table.table tr td.multi-file-table-name {
.multi-file-tabs { .multi-file-tabs {
display: flex; display: flex;
overflow-x: auto;
background-color: $white-normal; background-color: $white-normal;
box-shadow: inset 0 -1px $white-dark; box-shadow: inset 0 -1px $white-dark;
> li { > ul {
display: flex;
overflow-x: auto;
}
li {
position: relative; 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 { .multi-file-tab {
...@@ -160,20 +183,32 @@ table.table tr td.multi-file-table-name { ...@@ -160,20 +183,32 @@ table.table tr td.multi-file-table-name {
position: absolute; position: absolute;
right: 8px; right: 8px;
top: 50%; top: 50%;
width: 16px;
height: 16px;
padding: 0; padding: 0;
background: none; background: none;
border: 0; border: 0;
font-size: $gl-font-size; border-radius: $border-radius-default;
color: $gray-darkest; color: $theme-gray-900;
transform: translateY(-50%); transform: translateY(-50%);
&:not(.modified):hover, svg {
&:not(.modified):focus { position: relative;
color: $hint-color; top: -1px;
} }
&.modified { &:hover {
color: $indigo-700; 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 { ...@@ -192,6 +227,70 @@ table.table tr td.multi-file-table-name {
.vertical-center { .vertical-center {
min-height: auto; 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 { .multi-file-editor-holder {
...@@ -252,7 +351,7 @@ table.table tr td.multi-file-table-name { ...@@ -252,7 +351,7 @@ table.table tr td.multi-file-table-name {
display: flex; display: flex;
position: relative; position: relative;
flex-direction: column; flex-direction: column;
width: 290px; width: 340px;
padding: 0; padding: 0;
background-color: $gray-light; background-color: $gray-light;
padding-right: 3px; padding-right: 3px;
...@@ -350,6 +449,11 @@ table.table tr td.multi-file-table-name { ...@@ -350,6 +449,11 @@ table.table tr td.multi-file-table-name {
flex: 1; flex: 1;
} }
.multi-file-commit-empty-state-container {
align-items: center;
justify-content: center;
}
.multi-file-commit-panel-header { .multi-file-commit-panel-header {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -376,7 +480,7 @@ table.table tr td.multi-file-table-name { ...@@ -376,7 +480,7 @@ table.table tr td.multi-file-table-name {
.multi-file-commit-panel-header-title { .multi-file-commit-panel-header-title {
display: flex; display: flex;
flex: 1; flex: 1;
padding: $gl-btn-padding; padding: 0 $gl-btn-padding;
svg { svg {
margin-right: $gl-btn-padding; margin-right: $gl-btn-padding;
...@@ -390,12 +494,34 @@ table.table tr td.multi-file-table-name { ...@@ -390,12 +494,34 @@ table.table tr td.multi-file-table-name {
.multi-file-commit-list { .multi-file-commit-list {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
padding: $gl-padding; padding: $gl-padding 0;
min-height: 60px;
} }
.multi-file-commit-list-item { .multi-file-commit-list-item {
display: flex; display: flex;
padding: 0;
align-items: center; 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 { .multi-file-addition {
...@@ -414,29 +540,58 @@ table.table tr td.multi-file-table-name { ...@@ -414,29 +540,58 @@ table.table tr td.multi-file-table-name {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.file-status-icon {
width: 10px;
height: 10px;
margin-left: 3px;
}
} }
.multi-file-commit-list-path { .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%); @include str-truncated(100%);
&:hover {
text-decoration: underline;
}
&:active {
text-decoration: none;
}
} }
.multi-file-commit-form { .multi-file-commit-form {
padding: $gl-padding; padding: $gl-padding;
border-top: 1px solid $white-dark; border-top: 1px solid $white-dark;
}
.multi-file-commit-fieldset {
display: flex;
align-items: center;
padding-bottom: 12px;
.btn { .btn {
flex: 1; font-size: $gl-font-size;
} }
} }
.multi-file-commit-message.form-control { .multi-file-commit-message.form-control {
height: 80px; height: 160px;
resize: none; resize: none;
} }
...@@ -468,7 +623,7 @@ table.table tr td.multi-file-table-name { ...@@ -468,7 +623,7 @@ table.table tr td.multi-file-table-name {
top: 0; top: 0;
width: 100px; width: 100px;
height: 1px; 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 { ...@@ -487,7 +642,7 @@ table.table tr td.multi-file-table-name {
justify-content: center; justify-content: center;
} }
.repo-new-btn { .ide-new-btn {
.dropdown-toggle svg { .dropdown-toggle svg {
margin-top: -2px; margin-top: -2px;
margin-bottom: 2px; margin-bottom: 2px;
...@@ -505,7 +660,10 @@ table.table tr td.multi-file-table-name { ...@@ -505,7 +660,10 @@ table.table tr td.multi-file-table-name {
} }
} }
.ide.nav-only { .ide {
overflow: hidden;
&.nav-only {
.flash-container { .flash-container {
margin-top: $header-height; margin-top: $header-height;
margin-bottom: 0; margin-bottom: 0;
...@@ -516,25 +674,25 @@ table.table tr td.multi-file-table-name { ...@@ -516,25 +674,25 @@ table.table tr td.multi-file-table-name {
margin-bottom: 0; margin-bottom: 0;
} }
.content { .content-wrapper {
margin-top: $header-height; margin-top: $header-height;
} padding-bottom: 0;
.multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
max-height: calc(100vh - #{$header-height + $context-header-height});
} }
&.flash-shown { &.flash-shown {
.content { .content-wrapper {
margin-top: 0; margin-top: 0;
} }
.ide-view { .ide-view {
height: calc(100vh - #{$header-height + $flash-height}); height: calc(100vh - #{$header-height + $flash-height});
} }
}
.multi-file-commit-panel .multi-file-commit-panel-inner-scroll { .projects-sidebar {
max-height: calc(100vh - #{$header-height + $flash-height + $context-header-height}); .multi-file-commit-panel-inner-scroll {
flex: 1;
}
} }
} }
} }
...@@ -544,34 +702,28 @@ table.table tr td.multi-file-table-name { ...@@ -544,34 +702,28 @@ table.table tr td.multi-file-table-name {
margin-top: #{$header-height + $performance-bar-height}; margin-top: #{$header-height + $performance-bar-height};
} }
.content { .content-wrapper {
margin-top: #{$header-height + $performance-bar-height}; margin-top: #{$header-height + $performance-bar-height};
padding-bottom: 0;
} }
.ide-view { .ide-view {
height: calc(100vh - #{$header-height + $performance-bar-height}); 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 { &.flash-shown {
.content { .content-wrapper {
margin-top: 0; margin-top: 0;
} }
.ide-view { .ide-view {
height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height}); 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});
} }
} }
} }
.dragHandle { .dragHandle {
position: absolute; position: absolute;
top: 0; top: 0;
...@@ -587,3 +739,44 @@ table.table tr td.multi-file-table-name { ...@@ -587,3 +739,44 @@ table.table tr td.multi-file-table-name {
left: 0; 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;
}
}
class IdeController < ApplicationController
layout 'nav_only'
def index
end
end
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
- @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...')
...@@ -76,4 +76,8 @@ ...@@ -76,4 +76,8 @@
= render 'projects/find_file_link' = 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 = render 'projects/buttons/download', project: @project, ref: @ref
...@@ -61,6 +61,9 @@ Rails.application.routes.draw do ...@@ -61,6 +61,9 @@ Rails.application.routes.draw do
# UserCallouts # UserCallouts
resources :user_callouts, only: [:create] resources :user_callouts, only: [:create]
get 'ide' => 'ide#index'
get 'ide/*vueroute' => 'ide#index', format: false
end end
# Koding route # Koding route
......
...@@ -9,12 +9,14 @@ const StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin; ...@@ -9,12 +9,14 @@ const StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin;
const CopyWebpackPlugin = require('copy-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin'); const CompressionPlugin = require('compression-webpack-plugin');
const NameAllModulesPlugin = require('name-all-modules-plugin'); const NameAllModulesPlugin = require('name-all-modules-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
.BundleAnalyzerPlugin;
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const ROOT_PATH = path.resolve(__dirname, '..'); const ROOT_PATH = path.resolve(__dirname, '..');
const IS_PRODUCTION = process.env.NODE_ENV === 'production'; const IS_PRODUCTION = process.env.NODE_ENV === 'production';
const IS_DEV_SERVER = process.argv.join(' ').indexOf('webpack-dev-server') !== -1; const IS_DEV_SERVER =
process.argv.join(' ').indexOf('webpack-dev-server') !== -1;
const DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; const DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost';
const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808;
const DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; const DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false';
...@@ -27,10 +29,10 @@ let watchAutoEntries = []; ...@@ -27,10 +29,10 @@ let watchAutoEntries = [];
function generateEntries() { function generateEntries() {
// generate automatic entry points // generate automatic entry points
const autoEntries = {}; const autoEntries = {};
const pageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'app/assets/javascripts') }); const pageEntries = glob.sync('pages/**/index.js', {
watchAutoEntries = [ cwd: path.join(ROOT_PATH, 'app/assets/javascripts'),
path.join(ROOT_PATH, 'app/assets/javascripts/pages/'), });
]; watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')];
function generateAutoEntries(path, prefix = '.') { function generateAutoEntries(path, prefix = '.') {
const chunkPath = path.replace(/\/index\.js$/, ''); const chunkPath = path.replace(/\/index\.js$/, '');
...@@ -38,7 +40,7 @@ function generateEntries() { ...@@ -38,7 +40,7 @@ function generateEntries() {
autoEntries[chunkName] = `${prefix}/${path}`; autoEntries[chunkName] = `${prefix}/${path}`;
} }
pageEntries.forEach(( path ) => generateAutoEntries(path)); pageEntries.forEach(path => generateAutoEntries(path));
autoEntriesCount = Object.keys(autoEntries).length; autoEntriesCount = Object.keys(autoEntries).length;
...@@ -47,6 +49,7 @@ function generateEntries() { ...@@ -47,6 +49,7 @@ function generateEntries() {
main: './main.js', main: './main.js',
raven: './raven/index.js', raven: './raven/index.js',
webpack_runtime: './webpack.js', webpack_runtime: './webpack.js',
ide: './ide/index.js',
}; };
return Object.assign(manualEntries, autoEntries); return Object.assign(manualEntries, autoEntries);
...@@ -60,8 +63,12 @@ const config = { ...@@ -60,8 +63,12 @@ const config = {
output: { output: {
path: path.join(ROOT_PATH, 'public/assets/webpack'), path: path.join(ROOT_PATH, 'public/assets/webpack'),
publicPath: '/assets/webpack/', publicPath: '/assets/webpack/',
filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js', filename: IS_PRODUCTION
chunkFilename: IS_PRODUCTION ? '[name].[chunkhash].chunk.js' : '[name].chunk.js', ? '[name].[chunkhash].bundle.js'
: '[name].bundle.js',
chunkFilename: IS_PRODUCTION
? '[name].[chunkhash].chunk.js'
: '[name].chunk.js',
}, },
module: { module: {
...@@ -90,8 +97,8 @@ const config = { ...@@ -90,8 +97,8 @@ const config = {
{ {
loader: 'worker-loader', loader: 'worker-loader',
options: { options: {
inline: true inline: true,
} },
}, },
{ loader: 'babel-loader' }, { loader: 'babel-loader' },
], ],
...@@ -102,7 +109,7 @@ const config = { ...@@ -102,7 +109,7 @@ const config = {
loader: 'file-loader', loader: 'file-loader',
options: { options: {
name: '[name].[hash].[ext]', name: '[name].[hash].[ext]',
} },
}, },
{ {
test: /katex.css$/, test: /katex.css$/,
...@@ -112,8 +119,8 @@ const config = { ...@@ -112,8 +119,8 @@ const config = {
{ {
loader: 'css-loader', loader: 'css-loader',
options: { options: {
name: '[name].[hash].[ext]' name: '[name].[hash].[ext]',
} },
}, },
], ],
}, },
...@@ -123,15 +130,18 @@ const config = { ...@@ -123,15 +130,18 @@ const config = {
loader: 'file-loader', loader: 'file-loader',
options: { options: {
name: '[name].[hash].[ext]', name: '[name].[hash].[ext]',
} },
}, },
{ {
test: /monaco-editor\/\w+\/vs\/loader\.js$/, test: /monaco-editor\/\w+\/vs\/loader\.js$/,
use: [ use: [
{ loader: 'exports-loader', options: 'l.global' }, { loader: 'exports-loader', options: 'l.global' },
{ loader: 'imports-loader', options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined' }, {
loader: 'imports-loader',
options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined',
},
], ],
} },
], ],
noParse: [/monaco-editor\/\w+\/vs\//], noParse: [/monaco-editor\/\w+\/vs\//],
...@@ -149,10 +159,10 @@ const config = { ...@@ -149,10 +159,10 @@ const config = {
source: false, source: false,
chunks: false, chunks: false,
modules: false, modules: false,
assets: true assets: true,
}); });
return JSON.stringify(stats, null, 2); return JSON.stringify(stats, null, 2);
} },
}), }),
// prevent pikaday from including moment.js // prevent pikaday from including moment.js
...@@ -169,7 +179,7 @@ const config = { ...@@ -169,7 +179,7 @@ const config = {
new NameAllModulesPlugin(), new NameAllModulesPlugin(),
// assign deterministic chunk ids // assign deterministic chunk ids
new webpack.NamedChunksPlugin((chunk) => { new webpack.NamedChunksPlugin(chunk => {
if (chunk.name) { if (chunk.name) {
return chunk.name; return chunk.name;
} }
...@@ -186,9 +196,12 @@ const config = { ...@@ -186,9 +196,12 @@ const config = {
const pagesBase = path.join(ROOT_PATH, 'app/assets/javascripts/pages'); const pagesBase = path.join(ROOT_PATH, 'app/assets/javascripts/pages');
if (m.resource.indexOf(pagesBase) === 0) { if (m.resource.indexOf(pagesBase) === 0) {
moduleNames.push(path.relative(pagesBase, m.resource) moduleNames.push(
path
.relative(pagesBase, m.resource)
.replace(/\/index\.[a-z]+$/, '') .replace(/\/index\.[a-z]+$/, '')
.replace(/\//g, '__')); .replace(/\//g, '__'),
);
} else { } else {
moduleNames.push(path.relative(m.context, m.resource)); moduleNames.push(path.relative(m.context, m.resource));
} }
...@@ -196,7 +209,8 @@ const config = { ...@@ -196,7 +209,8 @@ const config = {
chunk.forEachModule(collectModuleNames); chunk.forEachModule(collectModuleNames);
const hash = crypto.createHash('sha256') const hash = crypto
.createHash('sha256')
.update(moduleNames.join('_')) .update(moduleNames.join('_'))
.digest('hex'); .digest('hex');
...@@ -214,10 +228,17 @@ const config = { ...@@ -214,10 +228,17 @@ const config = {
// copy pre-compiled vendor libraries verbatim // copy pre-compiled vendor libraries verbatim
new CopyWebpackPlugin([ new CopyWebpackPlugin([
{ {
from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`), from: path.join(
ROOT_PATH,
`node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`,
),
to: 'monaco-editor/vs', to: 'monaco-editor/vs',
transform: function(content, path) { transform: function(content, path) {
if (/\.js$/.test(path) && !/worker/i.test(path) && !/typescript/i.test(path)) { if (
/\.js$/.test(path) &&
!/worker/i.test(path) &&
!/typescript/i.test(path)
) {
return ( return (
'(function(){\n' + '(function(){\n' +
'var define = this.define, require = this.require;\n' + 'var define = this.define, require = this.require;\n' +
...@@ -227,8 +248,8 @@ const config = { ...@@ -227,8 +248,8 @@ const config = {
); );
} }
return content; return content;
} },
} },
]), ]),
], ],
...@@ -236,14 +257,14 @@ const config = { ...@@ -236,14 +257,14 @@ const config = {
extensions: ['.js'], extensions: ['.js'],
alias: { alias: {
'~': path.join(ROOT_PATH, 'app/assets/javascripts'), '~': path.join(ROOT_PATH, 'app/assets/javascripts'),
'emojis': path.join(ROOT_PATH, 'fixtures/emojis'), emojis: path.join(ROOT_PATH, 'fixtures/emojis'),
'empty_states': path.join(ROOT_PATH, 'app/views/shared/empty_states'), empty_states: path.join(ROOT_PATH, 'app/views/shared/empty_states'),
'icons': path.join(ROOT_PATH, 'app/views/shared/icons'), icons: path.join(ROOT_PATH, 'app/views/shared/icons'),
'images': path.join(ROOT_PATH, 'app/assets/images'), images: path.join(ROOT_PATH, 'app/assets/images'),
'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'), vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'),
'vue$': 'vue/dist/vue.esm.js', vue$: 'vue/dist/vue.esm.js',
'spec': path.join(ROOT_PATH, 'spec/javascripts'), spec: path.join(ROOT_PATH, 'spec/javascripts'),
} },
}, },
// sqljs requires fs // sqljs requires fs
...@@ -258,14 +279,14 @@ if (IS_PRODUCTION) { ...@@ -258,14 +279,14 @@ if (IS_PRODUCTION) {
new webpack.NoEmitOnErrorsPlugin(), new webpack.NoEmitOnErrorsPlugin(),
new webpack.LoaderOptionsPlugin({ new webpack.LoaderOptionsPlugin({
minimize: true, minimize: true,
debug: false debug: false,
}), }),
new webpack.optimize.UglifyJsPlugin({ new webpack.optimize.UglifyJsPlugin({
sourceMap: true sourceMap: true,
}), }),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': { NODE_ENV: JSON.stringify('production') } 'process.env': { NODE_ENV: JSON.stringify('production') },
}) }),
); );
// compression can require a lot of compute time and is disabled in CI // compression can require a lot of compute time and is disabled in CI
...@@ -283,7 +304,7 @@ if (IS_DEV_SERVER) { ...@@ -283,7 +304,7 @@ if (IS_DEV_SERVER) {
headers: { 'Access-Control-Allow-Origin': '*' }, headers: { 'Access-Control-Allow-Origin': '*' },
stats: 'errors-only', stats: 'errors-only',
hot: DEV_SERVER_LIVERELOAD, hot: DEV_SERVER_LIVERELOAD,
inline: DEV_SERVER_LIVERELOAD inline: DEV_SERVER_LIVERELOAD,
}; };
config.plugins.push( config.plugins.push(
// watch node_modules for changes if we encounter a missing module compile error // watch node_modules for changes if we encounter a missing module compile error
...@@ -299,12 +320,14 @@ if (IS_DEV_SERVER) { ...@@ -299,12 +320,14 @@ if (IS_DEV_SERVER) {
]; ];
// report our auto-generated bundle count // report our auto-generated bundle count
console.log(`${autoEntriesCount} entries from '/pages' automatically added to webpack output.`); console.log(
`${autoEntriesCount} entries from '/pages' automatically added to webpack output.`,
);
callback(); callback();
}) });
},
}, },
}
); );
if (DEV_SERVER_LIVERELOAD) { if (DEV_SERVER_LIVERELOAD) {
config.plugins.push(new webpack.HotModuleReplacementPlugin()); config.plugins.push(new webpack.HotModuleReplacementPlugin());
...@@ -319,7 +342,7 @@ if (WEBPACK_REPORT) { ...@@ -319,7 +342,7 @@ if (WEBPACK_REPORT) {
openAnalyzer: false, openAnalyzer: false,
reportFilename: path.join(ROOT_PATH, 'webpack-report/index.html'), reportFilename: path.join(ROOT_PATH, 'webpack-report/index.html'),
statsFilename: path.join(ROOT_PATH, 'webpack-report/stats.json'), statsFilename: path.join(ROOT_PATH, 'webpack-report/stats.json'),
}) }),
); );
} }
......
require 'spec_helper'
feature 'Multi-file editor new directory', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
before do
project.add_master(user)
sign_in(user)
visit project_tree_path(project, :master)
wait_for_requests
click_link('Web IDE')
wait_for_requests
end
after do
set_cookie('new_repo', 'false')
end
it 'creates directory in current directory' do
find('.add-to-tree').click
click_link('New directory')
page.within('.modal') do
find('.form-control').set('folder name')
click_button('Create directory')
end
find('.add-to-tree').click
click_link('New file')
page.within('.modal-dialog') do
find('.form-control').set('file name')
click_button('Create file')
end
wait_for_requests
fill_in('commit-message', with: 'commit message ide')
click_button('Commit')
expect(page).to have_content('folder name')
end
end
require 'spec_helper'
feature 'Multi-file editor new file', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
before do
project.add_master(user)
sign_in(user)
visit project_path(project)
wait_for_requests
click_link('Web IDE')
wait_for_requests
end
after do
set_cookie('new_repo', 'false')
end
it 'creates file in current directory' do
find('.add-to-tree').click
click_link('New file')
page.within('.modal') do
find('.form-control').set('file name')
click_button('Create file')
end
wait_for_requests
fill_in('commit-message', with: 'commit message ide')
click_button('Commit')
expect(page).to have_content('file name')
end
end
require 'spec_helper'
feature 'Multi-file editor upload file', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:txt_file) { File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt') }
let(:img_file) { File.join(Rails.root, 'spec', 'fixtures', 'dk.png') }
before do
project.add_master(user)
sign_in(user)
visit project_tree_path(project, :master)
wait_for_requests
click_link('Web IDE')
wait_for_requests
end
after do
set_cookie('new_repo', 'false')
end
it 'uploads text file' do
find('.add-to-tree').click
# make the field visible so capybara can use it
execute_script('document.querySelector("#file-upload").classList.remove("hidden")')
attach_file('file-upload', txt_file)
find('.add-to-tree').click
expect(page).to have_selector('.multi-file-tab', text: 'doc_sample.txt')
expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline))
end
it 'uploads image file' do
find('.add-to-tree').click
# make the field visible so capybara can use it
execute_script('document.querySelector("#file-upload").classList.remove("hidden")')
attach_file('file-upload', img_file)
find('.add-to-tree').click
expect(page).to have_selector('.multi-file-tab', text: 'dk.png')
expect(page).not_to have_selector('.monaco-editor')
end
end
import Vue from 'vue';
import changedFileIcon from 'ee/ide/components/changed_file_icon.vue';
import createComponent from 'spec/helpers/vue_mount_component_helper';
describe('IDE changed file icon', () => {
let vm;
beforeEach(() => {
const component = Vue.extend(changedFileIcon);
vm = createComponent(component, {
file: {
tempFile: false,
},
});
});
afterEach(() => {
vm.$destroy();
});
describe('changedIcon', () => {
it('equals file-modified when not a temp file', () => {
expect(vm.changedIcon).toBe('file-modified');
});
it('equals file-addition when a temp file', () => {
vm.file.tempFile = true;
expect(vm.changedIcon).toBe('file-addition');
});
});
describe('changedIconClass', () => {
it('includes multi-file-modified when not a temp file', () => {
expect(vm.changedIconClass).toContain('multi-file-modified');
});
it('includes multi-file-addition when a temp file', () => {
vm.file.tempFile = true;
expect(vm.changedIconClass).toContain('multi-file-addition');
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import commitActions from 'ee/ide/components/commit_sidebar/actions.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from 'spec/ide/helpers';
describe('IDE commit sidebar actions', () => {
let vm;
beforeEach((done) => {
const Component = Vue.extend(commitActions);
vm = createComponentWithStore(Component, store);
vm.$store.state.currentBranchId = 'master';
vm.$mount();
Vue.nextTick(done);
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders 3 groups', () => {
expect(vm.$el.querySelectorAll('input[type="radio"]').length).toBe(3);
});
it('renders current branch text', () => {
expect(vm.$el.textContent).toContain('Commit to master branch');
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import listCollapsed from 'ee/ide/components/commit_sidebar/list_collapsed.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { file } from '../../helpers';
describe('Multi-file editor commit sidebar list collapsed', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(listCollapsed);
vm = createComponentWithStore(Component, store);
vm.$store.state.changedFiles.push(file('file1'), file('file2'));
vm.$store.state.changedFiles[0].tempFile = true;
vm.$mount();
});
afterEach(() => {
vm.$destroy();
});
it('renders added & modified files count', () => {
expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1');
});
});
import Vue from 'vue';
import listItem from 'ee/ide/components/commit_sidebar/list_item.vue';
import router from 'ee/ide/ide_router';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { file } from '../../helpers';
describe('Multi-file editor commit sidebar list item', () => {
let vm;
let f;
beforeEach(() => {
const Component = Vue.extend(listItem);
f = file('test-file');
vm = mountComponent(Component, {
file: f,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders file path', () => {
expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path);
});
it('calls discardFileChanges when clicking discard button', () => {
spyOn(vm, 'discardFileChanges');
vm.$el.querySelector('.multi-file-discard-btn').click();
expect(vm.discardFileChanges).toHaveBeenCalled();
});
it('opens a closed file in the editor when clicking the file path', () => {
spyOn(vm, 'openFileInEditor').and.callThrough();
spyOn(vm, 'updateViewer');
spyOn(router, 'push');
vm.$el.querySelector('.multi-file-commit-list-path').click();
expect(vm.openFileInEditor).toHaveBeenCalled();
expect(router.push).toHaveBeenCalled();
});
it('calls updateViewer with diff when clicking file', () => {
spyOn(vm, 'openFileInEditor').and.callThrough();
spyOn(vm, 'updateViewer');
spyOn(router, 'push');
vm.$el.querySelector('.multi-file-commit-list-path').click();
expect(vm.updateViewer).toHaveBeenCalledWith('diff');
});
describe('computed', () => {
describe('iconName', () => {
it('returns modified when not a tempFile', () => {
expect(vm.iconName).toBe('file-modified');
});
it('returns addition when not a tempFile', () => {
f.tempFile = true;
expect(vm.iconName).toBe('file-addition');
});
});
describe('iconClass', () => {
it('returns modified when not a tempFile', () => {
expect(vm.iconClass).toContain('multi-file-modified');
});
it('returns addition when not a tempFile', () => {
f.tempFile = true;
expect(vm.iconClass).toContain('multi-file-addition');
});
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import commitSidebarList from 'ee/ide/components/commit_sidebar/list.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { file } from '../../helpers';
describe('Multi-file editor commit sidebar list', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(commitSidebarList);
vm = createComponentWithStore(Component, store, {
title: 'Staged',
fileList: [],
});
vm.$store.state.rightPanelCollapsed = false;
vm.$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('with a list of files', () => {
beforeEach((done) => {
const f = file('file name');
f.changed = true;
vm.fileList.push(f);
Vue.nextTick(done);
});
it('renders list', () => {
expect(vm.$el.querySelectorAll('li').length).toBe(1);
});
});
describe('collapsed', () => {
beforeEach((done) => {
vm.$store.state.rightPanelCollapsed = true;
Vue.nextTick(done);
});
it('hides list', () => {
expect(vm.$el.querySelector('.list-unstyled')).toBeNull();
expect(vm.$el.querySelector('.help-block')).toBeNull();
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import radioGroup from 'ee/ide/components/commit_sidebar/radio_group.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from 'spec/ide/helpers';
describe('IDE commit sidebar radio group', () => {
let vm;
beforeEach((done) => {
const Component = Vue.extend(radioGroup);
store.state.commit.commitAction = '2';
vm = createComponentWithStore(Component, store, {
value: '1',
label: 'test',
checked: true,
});
vm.$mount();
Vue.nextTick(done);
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('uses label if present', () => {
expect(vm.$el.textContent).toContain('test');
});
it('uses slot if label is not present', (done) => {
vm.$destroy();
vm = new Vue({
components: {
radioGroup,
},
store,
template: `
<radio-group
value="1"
>
Testing slot
</radio-group>
`,
});
vm.$mount();
Vue.nextTick(() => {
expect(vm.$el.textContent).toContain('Testing slot');
done();
});
});
it('updates store when changing radio button', (done) => {
vm.$el.querySelector('input').dispatchEvent(new Event('change'));
Vue.nextTick(() => {
expect(store.state.commit.commitAction).toBe('1');
done();
});
});
it('renders helpText tooltip', (done) => {
vm.helpText = 'help text';
Vue.nextTick(() => {
const help = vm.$el.querySelector('.help-block');
expect(help).not.toBeNull();
expect(help.getAttribute('data-original-title')).toBe('help text');
done();
});
});
describe('with input', () => {
beforeEach((done) => {
vm.$destroy();
const Component = Vue.extend(radioGroup);
store.state.commit.commitAction = '1';
vm = createComponentWithStore(Component, store, {
value: '1',
label: 'test',
checked: true,
showInput: true,
});
vm.$mount();
Vue.nextTick(done);
});
it('renders input box when commitAction matches value', () => {
expect(vm.$el.querySelector('.form-control')).not.toBeNull();
});
it('hides input when commitAction doesnt match value', (done) => {
store.state.commit.commitAction = '2';
Vue.nextTick(() => {
expect(vm.$el.querySelector('.form-control')).toBeNull();
done();
});
});
it('updates branch name in store on input', (done) => {
const input = vm.$el.querySelector('.form-control');
input.value = 'testing-123';
input.dispatchEvent(new Event('input'));
Vue.nextTick(() => {
expect(store.state.commit.newBranchName).toBe('testing-123');
done();
});
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import ideContextBar from 'ee/ide/components/ide_context_bar.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('Multi-file editor right context bar', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(ideContextBar);
vm = createComponentWithStore(Component, store, {
noChangesStateSvgPath: 'svg',
committedStateSvgPath: 'svg',
});
vm.$store.state.rightPanelCollapsed = false;
vm.$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('collapsed', () => {
beforeEach((done) => {
vm.$store.state.rightPanelCollapsed = true;
Vue.nextTick(done);
});
it('adds collapsed class', () => {
expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull();
});
});
});
import Vue from 'vue';
import ideExternalLinks from 'ee/ide/components/ide_external_links.vue';
import createComponent from 'spec/helpers/vue_mount_component_helper';
describe('ide external links component', () => {
let vm;
let fakeReferrer;
let Component;
const fakeProjectUrl = '/project/';
beforeEach(() => {
Component = Vue.extend(ideExternalLinks);
});
afterEach(() => {
vm.$destroy();
});
describe('goBackUrl', () => {
it('renders the Go Back link with the referrer when present', () => {
fakeReferrer = '/example/README.md';
spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
vm = createComponent(Component, {
projectUrl: fakeProjectUrl,
}).$mount();
expect(vm.goBackUrl).toEqual(fakeReferrer);
});
it('renders the Go Back link with the project url when referrer is not present', () => {
fakeReferrer = '';
spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
vm = createComponent(Component, {
projectUrl: fakeProjectUrl,
}).$mount();
expect(vm.goBackUrl).toEqual(fakeProjectUrl);
});
});
});
import Vue from 'vue';
import ideRepoTree from 'ee/ide/components/ide_repo_tree.vue';
import createComponent from '../../helpers/vue_mount_component_helper';
import { file } from '../helpers';
describe('IdeRepoTree', () => {
let vm;
let tree;
beforeEach(() => {
const IdeRepoTree = Vue.extend(ideRepoTree);
tree = {
tree: [file()],
loading: false,
};
vm = createComponent(IdeRepoTree, {
tree,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders a sidebar', () => {
expect(vm.$el.querySelector('.loading-file')).toBeNull();
expect(vm.$el.querySelector('.file')).not.toBeNull();
});
it('renders 3 loading files if tree is loading', (done) => {
tree.loading = true;
vm.$nextTick(() => {
expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toEqual(3);
done();
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import ideSidebar from 'ee/ide/components/ide_side_bar.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
describe('IdeSidebar', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(ideSidebar);
vm = createComponentWithStore(Component, store).$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders a sidebar', () => {
expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
});
it('renders loading icon component', (done) => {
vm.$store.state.loading = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
done();
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import ide from 'ee/ide/components/ide.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { file, resetStore } from '../helpers';
describe('ide component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(ide);
vm = createComponentWithStore(Component, store, {
emptyStateSvgPath: 'svg',
noChangesStateSvgPath: 'svg',
committedStateSvgPath: 'svg',
}).$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('does not render panel right when no files open', () => {
expect(vm.$el.querySelector('.panel-right')).toBeNull();
});
it('renders panel right when files are open', (done) => {
vm.$store.state.trees['abcproject/mybranch'] = {
tree: [file()],
};
Vue.nextTick(() => {
expect(vm.$el.querySelector('.panel-right')).toBeNull();
done();
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import newDropdown from 'ee/ide/components/new_dropdown/index.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../../helpers';
describe('new dropdown component', () => {
let vm;
beforeEach(() => {
const component = Vue.extend(newDropdown);
vm = createComponentWithStore(component, store, {
branch: 'master',
path: '',
});
vm.$store.state.currentProjectId = 'abcproject';
vm.$store.state.path = '';
vm.$store.state.trees['abcproject/mybranch'] = {
tree: [],
};
vm.$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders new file, upload and new directory links', () => {
expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file');
expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe(
'Upload file',
);
expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe(
'New directory',
);
});
describe('createNewItem', () => {
it('sets modalType to blob when new file is clicked', () => {
vm.$el.querySelectorAll('a')[0].click();
expect(vm.modalType).toBe('blob');
});
it('sets modalType to tree when new directory is clicked', () => {
vm.$el.querySelectorAll('a')[2].click();
expect(vm.modalType).toBe('tree');
});
it('opens modal when link is clicked', done => {
vm.$el.querySelectorAll('a')[0].click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.modal')).not.toBeNull();
done();
});
});
});
describe('hideModal', () => {
beforeAll(done => {
vm.openModal = true;
Vue.nextTick(done);
});
it('closes modal after toggling', done => {
vm.hideModal();
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.modal')).toBeNull();
})
.then(done)
.catch(done.fail);
});
});
});
import Vue from 'vue';
import modal from 'ee/ide/components/new_dropdown/modal.vue';
import createComponent from 'spec/helpers/vue_mount_component_helper';
describe('new file modal component', () => {
const Component = Vue.extend(modal);
let vm;
afterEach(() => {
vm.$destroy();
});
['tree', 'blob'].forEach((type) => {
describe(type, () => {
beforeEach(() => {
vm = createComponent(Component, {
type,
branchId: 'master',
path: '',
});
vm.entryName = 'testing';
});
it(`sets modal title as ${type}`, () => {
const title = type === 'tree' ? 'directory' : 'file';
expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Create new ${title}`);
});
it(`sets button label as ${type}`, () => {
const title = type === 'tree' ? 'directory' : 'file';
expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Create ${title}`);
});
it(`sets form label as ${type}`, () => {
const title = type === 'tree' ? 'Directory' : 'File';
expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`);
});
describe('createEntryInStore', () => {
it('$emits create', () => {
spyOn(vm, '$emit');
vm.createEntryInStore();
expect(vm.$emit).toHaveBeenCalledWith('create', {
branchId: 'master',
name: 'testing',
type,
});
});
});
});
});
it('focuses field on mount', () => {
document.body.innerHTML += '<div class="js-test"></div>';
vm = createComponent(Component, {
type: 'tree',
branchId: 'master',
path: '',
}, '.js-test');
expect(document.activeElement).toBe(vm.$refs.fieldName);
vm.$el.remove();
});
});
import Vue from 'vue';
import upload from 'ee/ide/components/new_dropdown/upload.vue';
import createComponent from 'spec/helpers/vue_mount_component_helper';
describe('new dropdown upload', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(upload);
vm = createComponent(Component, {
branchId: 'master',
path: '',
});
vm.entryName = 'testing';
spyOn(vm, '$emit');
});
afterEach(() => {
vm.$destroy();
});
describe('readFile', () => {
beforeEach(() => {
spyOn(FileReader.prototype, 'readAsText');
spyOn(FileReader.prototype, 'readAsDataURL');
});
it('calls readAsText for text files', () => {
const file = {
type: 'text/html',
};
vm.readFile(file);
expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file);
});
it('calls readAsDataURL for non-text files', () => {
const file = {
type: 'images/png',
};
vm.readFile(file);
expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file);
});
});
describe('createFile', () => {
const target = {
result: 'content',
};
const binaryTarget = {
result: 'base64,base64content',
};
const file = {
name: 'file',
};
it('creates new file', () => {
vm.createFile(target, file, true);
expect(vm.$emit).toHaveBeenCalledWith('create', {
name: file.name,
branchId: 'master',
type: 'blob',
content: target.result,
base64: false,
});
});
it('splits content on base64 if binary', () => {
vm.createFile(binaryTarget, file, false);
expect(vm.$emit).toHaveBeenCalledWith('create', {
name: file.name,
branchId: 'master',
type: 'blob',
content: binaryTarget.result.split('base64,')[1],
base64: true,
});
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import service from 'ee/ide/services';
import repoCommitSection from 'ee/ide/components/repo_commit_section.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
import { file, resetStore } from '../helpers';
describe('RepoCommitSection', () => {
let vm;
function createComponent() {
const Component = Vue.extend(repoCommitSection);
vm = createComponentWithStore(Component, store, {
noChangesStateSvgPath: 'svg',
committedStateSvgPath: 'commitsvg',
});
vm.$store.state.currentProjectId = 'abcproject';
vm.$store.state.currentBranchId = 'master';
vm.$store.state.projects.abcproject = {
web_url: '',
branches: {
master: {
workingReference: '1',
},
},
};
vm.$store.state.rightPanelCollapsed = false;
vm.$store.state.currentBranch = 'master';
vm.$store.state.changedFiles = [file('file1'), file('file2')];
vm.$store.state.changedFiles.forEach(f => Object.assign(f, {
changed: true,
content: 'testing',
}));
return vm.$mount();
}
beforeEach((done) => {
vm = createComponent();
spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({
headers: {
'page-title': 'test',
},
json: () => Promise.resolve({
last_commit_path: 'last_commit_path',
parent_tree_url: 'parent_tree_url',
path: '/',
trees: [{ name: 'tree' }],
blobs: [{ name: 'blob' }],
submodules: [{ name: 'submodule' }],
}),
}));
Vue.nextTick(done);
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
describe('empty Stage', () => {
it('renders no changes text', () => {
resetStore(vm.$store);
const Component = Vue.extend(repoCommitSection);
vm = createComponentWithStore(Component, store, {
noChangesStateSvgPath: 'nochangessvg',
committedStateSvgPath: 'svg',
}).$mount();
expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes');
expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('nochangessvg');
});
});
it('renders a commit section', () => {
const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')];
const submitCommit = vm.$el.querySelector('form .btn');
expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull();
expect(changedFileElements.length).toEqual(2);
changedFileElements.forEach((changedFile, i) => {
expect(changedFile.textContent.trim()).toContain(vm.$store.state.changedFiles[i].path);
});
expect(submitCommit.disabled).toBeTruthy();
expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull();
});
it('updates commitMessage in store on input', (done) => {
const textarea = vm.$el.querySelector('textarea');
textarea.value = 'testing commit message';
textarea.dispatchEvent(new Event('input'));
getSetTimeoutPromise()
.then(() => {
expect(vm.$store.state.commit.commitMessage).toBe('testing commit message');
})
.then(done)
.catch(done.fail);
});
describe('discard draft button', () => {
it('hidden when commitMessage is empty', () => {
expect(vm.$el.querySelector('.multi-file-commit-form .btn-default')).toBeNull();
});
it('resets commitMessage when clicking discard button', (done) => {
vm.$store.state.commit.commitMessage = 'testing commit message';
getSetTimeoutPromise()
.then(() => {
vm.$el.querySelector('.multi-file-commit-form .btn-default').click();
})
.then(Vue.nextTick)
.then(() => {
expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message');
})
.then(done)
.catch(done.fail);
});
});
describe('when submitting', () => {
beforeEach(() => {
spyOn(vm, 'commitChanges');
});
it('calls commitChanges', (done) => {
vm.$store.state.commit.commitMessage = 'testing commit message';
getSetTimeoutPromise()
.then(() => {
vm.$el.querySelector('.multi-file-commit-form .btn-success').click();
})
.then(Vue.nextTick)
.then(() => {
expect(vm.commitChanges).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import repoEditor from 'ee/ide/components/repo_editor.vue';
import monacoLoader from 'ee/ide/monaco_loader';
import Editor from 'ee/ide/lib/editor';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { file, resetStore } from '../helpers';
describe('RepoEditor', () => {
let vm;
beforeEach((done) => {
const f = file();
const RepoEditor = Vue.extend(repoEditor);
vm = createComponentWithStore(RepoEditor, store, {
file: f,
});
f.active = true;
f.tempFile = true;
f.html = 'testing';
vm.$store.state.openFiles.push(f);
vm.$store.state.entries[f.path] = f;
vm.monaco = true;
vm.$mount();
monacoLoader(['vs/editor/editor.main'], () => {
setTimeout(done, 0);
});
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
Editor.editorInstance.modelManager.dispose();
});
it('renders an ide container', (done) => {
Vue.nextTick(() => {
expect(vm.shouldHideEditor).toBeFalsy();
done();
});
});
describe('when open file is binary and not raw', () => {
beforeEach((done) => {
vm.file.binary = true;
vm.$nextTick(done);
});
it('does not render the IDE', () => {
expect(vm.shouldHideEditor).toBeTruthy();
});
it('shows activeFile html', () => {
expect(vm.$el.textContent).toContain('testing');
});
});
describe('createEditorInstance', () => {
it('calls createInstance when viewer is editor', (done) => {
spyOn(vm.editor, 'createInstance');
vm.createEditorInstance();
vm.$nextTick(() => {
expect(vm.editor.createInstance).toHaveBeenCalled();
done();
});
});
it('calls createDiffInstance when viewer is diff', (done) => {
vm.$store.state.viewer = 'diff';
spyOn(vm.editor, 'createDiffInstance');
vm.createEditorInstance();
vm.$nextTick(() => {
expect(vm.editor.createDiffInstance).toHaveBeenCalled();
done();
});
});
});
describe('setupEditor', () => {
it('creates new model', () => {
spyOn(vm.editor, 'createModel').and.callThrough();
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file);
expect(vm.model).not.toBeNull();
});
it('attaches model to editor', () => {
spyOn(vm.editor, 'attachModel').and.callThrough();
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model);
});
it('adds callback methods', () => {
spyOn(vm.editor, 'onPositionChange').and.callThrough();
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
expect(vm.editor.onPositionChange).toHaveBeenCalled();
expect(vm.model.events.size).toBe(1);
});
it('updates state when model content changed', (done) => {
vm.model.setValue('testing 123');
setTimeout(() => {
expect(vm.file.content).toBe('testing 123');
done();
});
});
});
});
import Vue from 'vue';
import repoFileButtons from 'ee/ide/components/repo_file_buttons.vue';
import createVueComponent from '../../helpers/vue_mount_component_helper';
import { file } from '../helpers';
describe('RepoFileButtons', () => {
const activeFile = file();
let vm;
function createComponent() {
const RepoFileButtons = Vue.extend(repoFileButtons);
activeFile.rawPath = 'test';
activeFile.blamePath = 'test';
activeFile.commitsPath = 'test';
return createVueComponent(RepoFileButtons, {
file: activeFile,
});
}
afterEach(() => {
vm.$destroy();
});
it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => {
vm = createComponent();
vm.$nextTick(() => {
const raw = vm.$el.querySelector('.raw');
const blame = vm.$el.querySelector('.blame');
const history = vm.$el.querySelector('.history');
expect(raw.href).toMatch(`/${activeFile.rawPath}`);
expect(raw.textContent.trim()).toEqual('Raw');
expect(blame.href).toMatch(`/${activeFile.blamePath}`);
expect(blame.textContent.trim()).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commitsPath}`);
expect(history.textContent.trim()).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
done();
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import repoFile from 'ee/ide/components/repo_file.vue';
import router from 'ee/ide/ide_router';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { file } from '../helpers';
describe('RepoFile', () => {
let vm;
function createComponent(propsData) {
const RepoFile = Vue.extend(repoFile);
vm = createComponentWithStore(RepoFile, store, propsData);
vm.$mount();
}
afterEach(() => {
vm.$destroy();
});
it('renders link, icon and name', () => {
createComponent({
file: file('t4'),
level: 0,
});
const name = vm.$el.querySelector('.ide-file-name');
expect(name.href).toMatch('');
expect(name.textContent.trim()).toEqual(vm.file.name);
});
it('fires clickFile when the link is clicked', done => {
spyOn(router, 'push');
createComponent({
file: file('t3'),
level: 0,
});
vm.$el.querySelector('.file-name').click();
setTimeout(() => {
expect(router.push).toHaveBeenCalledWith(`/project${vm.file.url}`);
done();
});
});
describe('locked file', () => {
let f;
beforeEach(() => {
f = file('locked file');
f.file_lock = {
user: {
name: 'testuser',
updated_at: new Date(),
},
};
createComponent({
file: f,
level: 0,
});
});
it('renders lock icon', () => {
expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
});
it('renders a tooltip', () => {
expect(
vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset
.originalTitle,
).toContain('Locked by testuser');
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import repoLoadingFile from 'ee/ide/components/repo_loading_file.vue';
import { resetStore } from '../helpers';
describe('RepoLoadingFile', () => {
let vm;
function createComponent() {
const RepoLoadingFile = Vue.extend(repoLoadingFile);
return new RepoLoadingFile({
store,
}).$mount();
}
function assertLines(lines) {
lines.forEach((line, n) => {
const index = n + 1;
expect(line.classList.contains(`skeleton-line-${index}`)).toBeTruthy();
});
}
function assertColumns(columns) {
columns.forEach((column) => {
const container = column.querySelector('.animation-container');
const lines = [...container.querySelectorAll(':scope > div')];
expect(container).toBeTruthy();
expect(lines.length).toEqual(6);
assertLines(lines);
});
}
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders 3 columns of animated LoC', () => {
vm = createComponent();
const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(3);
assertColumns(columns);
});
it('renders 1 column of animated LoC if isMini', (done) => {
vm = createComponent();
vm.$store.state.leftPanelCollapsed = true;
vm.$store.state.openFiles.push('test');
vm.$nextTick(() => {
const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(1);
assertColumns(columns);
done();
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import repoTab from 'ee/ide/components/repo_tab.vue';
import router from 'ee/ide/ide_router';
import { file, resetStore } from '../helpers';
describe('RepoTab', () => {
let vm;
function createComponent(propsData) {
const RepoTab = Vue.extend(repoTab);
return new RepoTab({
store,
propsData,
}).$mount();
}
beforeEach(() => {
spyOn(router, 'push');
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders a close link and a name link', () => {
vm = createComponent({
tab: file(),
});
vm.$store.state.openFiles.push(vm.tab);
const close = vm.$el.querySelector('.multi-file-tab-close');
const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`);
expect(close.innerHTML).toContain('#close');
expect(name.textContent.trim()).toEqual(vm.tab.name);
});
it('fires clickFile when the link is clicked', () => {
vm = createComponent({
tab: file(),
});
spyOn(vm, 'clickFile');
vm.$el.click();
expect(vm.clickFile).toHaveBeenCalledWith(vm.tab);
});
it('calls closeFile when clicking close button', () => {
vm = createComponent({
tab: file(),
});
spyOn(vm, 'closeFile');
vm.$el.querySelector('.multi-file-tab-close').click();
expect(vm.closeFile).toHaveBeenCalledWith(vm.tab.path);
});
it('changes icon on hover', (done) => {
const tab = file();
tab.changed = true;
vm = createComponent({
tab,
});
vm.$el.dispatchEvent(new Event('mouseover'));
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.multi-file-modified')).toBeNull();
vm.$el.dispatchEvent(new Event('mouseout'));
})
.then(Vue.nextTick)
.then(() => {
expect(vm.$el.querySelector('.multi-file-modified')).not.toBeNull();
done();
})
.catch(done.fail);
});
describe('locked file', () => {
let f;
beforeEach(() => {
f = file('locked file');
f.file_lock = {
user: {
name: 'testuser',
updated_at: new Date(),
},
};
vm = createComponent({
tab: f,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders lock icon', () => {
expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
});
it('renders a tooltip', () => {
expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain('Locked by testuser');
});
});
describe('methods', () => {
describe('closeTab', () => {
it('closes tab if file has changed', (done) => {
const tab = file();
tab.changed = true;
tab.opened = true;
vm = createComponent({
tab,
});
vm.$store.state.openFiles.push(tab);
vm.$store.state.changedFiles.push(tab);
vm.$store.state.entries[tab.path] = tab;
vm.$store.dispatch('setFileActive', tab.path);
vm.$el.querySelector('.multi-file-tab-close').click();
vm.$nextTick(() => {
expect(tab.opened).toBeFalsy();
expect(vm.$store.state.changedFiles.length).toBe(1);
done();
});
});
it('closes tab when clicking close btn', (done) => {
const tab = file('lose');
tab.opened = true;
vm = createComponent({
tab,
});
vm.$store.state.openFiles.push(tab);
vm.$store.state.entries[tab.path] = tab;
vm.$store.dispatch('setFileActive', tab.path);
vm.$el.querySelector('.multi-file-tab-close').click();
vm.$nextTick(() => {
expect(tab.opened).toBeFalsy();
done();
});
});
});
});
});
import Vue from 'vue';
import repoTabs from 'ee/ide/components/repo_tabs.vue';
import createComponent from '../../helpers/vue_mount_component_helper';
import { file } from '../helpers';
describe('RepoTabs', () => {
const openedFiles = [file('open1'), file('open2')];
const RepoTabs = Vue.extend(repoTabs);
let vm;
afterEach(() => {
vm.$destroy();
});
it('renders a list of tabs', done => {
vm = createComponent(RepoTabs, {
files: openedFiles,
viewer: 'editor',
hasChanges: false,
});
openedFiles[0].active = true;
vm.$nextTick(() => {
const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')];
expect(tabs.length).toEqual(2);
expect(tabs[0].classList.contains('active')).toEqual(true);
expect(tabs[1].classList.contains('active')).toEqual(false);
done();
});
});
describe('updated', () => {
it('sets showShadow as true when scroll width is larger than width', done => {
const el = document.createElement('div');
el.innerHTML = '<div id="test-app"></div>';
document.body.appendChild(el);
const style = document.createElement('style');
style.innerText = `
.multi-file-tabs {
width: 100px;
}
.multi-file-tabs .list-unstyled {
display: flex;
overflow-x: auto;
}
`;
document.head.appendChild(style);
vm = createComponent(
RepoTabs,
{
files: [],
viewer: 'editor',
hasChanges: false,
},
'#test-app',
);
vm
.$nextTick()
.then(() => {
expect(vm.showShadow).toEqual(false);
vm.files = openedFiles;
})
.then(vm.$nextTick)
.then(() => {
expect(vm.showShadow).toEqual(true);
style.remove();
el.remove();
})
.then(done)
.catch(done.fail);
});
});
});
import { decorateData } from 'ee/ide/stores/utils';
import state from 'ee/ide/stores/state';
import commitState from 'ee/ide/stores/modules/commit/state';
export const resetStore = (store) => {
const newState = {
...state(),
commit: commitState(),
};
store.replaceState(newState);
};
export const file = (name = 'name', id = name, type = '') => decorateData({
id,
type,
icon: 'icon',
url: 'url',
name,
path: name,
lastCommit: {},
});
import Disposable from 'ee/ide/lib/common/disposable';
describe('Multi-file editor library disposable class', () => {
let instance;
let disposableClass;
beforeEach(() => {
instance = new Disposable();
disposableClass = {
dispose: jasmine.createSpy('dispose'),
};
});
afterEach(() => {
instance.dispose();
});
describe('add', () => {
it('adds disposable classes', () => {
instance.add(disposableClass);
expect(instance.disposers.size).toBe(1);
});
});
describe('dispose', () => {
beforeEach(() => {
instance.add(disposableClass);
});
it('calls dispose on all cached disposers', () => {
instance.dispose();
expect(disposableClass.dispose).toHaveBeenCalled();
});
it('clears cached disposers', () => {
instance.dispose();
expect(instance.disposers.size).toBe(0);
});
});
});
/* global monaco */
import eventHub from 'ee/ide/eventhub';
import monacoLoader from 'ee/ide/monaco_loader';
import ModelManager from 'ee/ide/lib/common/model_manager';
import { file } from '../../helpers';
describe('Multi-file editor library model manager', () => {
let instance;
beforeEach((done) => {
monacoLoader(['vs/editor/editor.main'], () => {
instance = new ModelManager(monaco);
done();
});
});
afterEach(() => {
instance.dispose();
});
describe('addModel', () => {
it('caches model', () => {
instance.addModel(file());
expect(instance.models.size).toBe(1);
});
it('caches model by file path', () => {
instance.addModel(file('path-name'));
expect(instance.models.keys().next().value).toBe('path-name');
});
it('adds model into disposable', () => {
spyOn(instance.disposable, 'add').and.callThrough();
instance.addModel(file());
expect(instance.disposable.add).toHaveBeenCalled();
});
it('returns cached model', () => {
spyOn(instance.models, 'get').and.callThrough();
instance.addModel(file());
instance.addModel(file());
expect(instance.models.get).toHaveBeenCalled();
});
it('adds eventHub listener', () => {
const f = file();
spyOn(eventHub, '$on').and.callThrough();
instance.addModel(f);
expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything());
});
});
describe('hasCachedModel', () => {
it('returns false when no models exist', () => {
expect(instance.hasCachedModel('path')).toBeFalsy();
});
it('returns true when model exists', () => {
instance.addModel(file('path-name'));
expect(instance.hasCachedModel('path-name')).toBeTruthy();
});
});
describe('getModel', () => {
it('returns cached model', () => {
instance.addModel(file('path-name'));
expect(instance.getModel('path-name')).not.toBeNull();
});
});
describe('removeCachedModel', () => {
let f;
beforeEach(() => {
f = file();
instance.addModel(f);
});
it('clears cached model', () => {
instance.removeCachedModel(f);
expect(instance.models.size).toBe(0);
});
it('removes eventHub listener', () => {
spyOn(eventHub, '$off').and.callThrough();
instance.removeCachedModel(f);
expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything());
});
});
describe('dispose', () => {
it('clears cached models', () => {
instance.addModel(file());
instance.dispose();
expect(instance.models.size).toBe(0);
});
it('calls disposable dispose', () => {
spyOn(instance.disposable, 'dispose').and.callThrough();
instance.dispose();
expect(instance.disposable.dispose).toHaveBeenCalled();
});
});
});
/* global monaco */
import eventHub from 'ee/ide/eventhub';
import monacoLoader from 'ee/ide/monaco_loader';
import Model from 'ee/ide/lib/common/model';
import { file } from '../../helpers';
describe('Multi-file editor library model', () => {
let model;
beforeEach((done) => {
spyOn(eventHub, '$on').and.callThrough();
monacoLoader(['vs/editor/editor.main'], () => {
model = new Model(monaco, file('path'));
done();
});
});
afterEach(() => {
model.dispose();
});
it('creates original model & new model', () => {
expect(model.originalModel).not.toBeNull();
expect(model.model).not.toBeNull();
});
it('adds eventHub listener', () => {
expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything());
});
describe('path', () => {
it('returns file path', () => {
expect(model.path).toBe('path');
});
});
describe('getModel', () => {
it('returns model', () => {
expect(model.getModel()).toBe(model.model);
});
});
describe('getOriginalModel', () => {
it('returns original model', () => {
expect(model.getOriginalModel()).toBe(model.originalModel);
});
});
describe('setValue', () => {
it('updates models value', () => {
model.setValue('testing 123');
expect(model.getModel().getValue()).toBe('testing 123');
});
});
describe('onChange', () => {
it('caches event by path', () => {
model.onChange(() => {});
expect(model.events.size).toBe(1);
expect(model.events.keys().next().value).toBe('path');
});
it('calls callback on change', (done) => {
const spy = jasmine.createSpy();
model.onChange(spy);
model.getModel().setValue('123');
setTimeout(() => {
expect(spy).toHaveBeenCalledWith(model, jasmine.anything());
done();
});
});
});
describe('dispose', () => {
it('calls disposable dispose', () => {
spyOn(model.disposable, 'dispose').and.callThrough();
model.dispose();
expect(model.disposable.dispose).toHaveBeenCalled();
});
it('clears events', () => {
model.onChange(() => {});
expect(model.events.size).toBe(1);
model.dispose();
expect(model.events.size).toBe(0);
});
it('removes eventHub listener', () => {
spyOn(eventHub, '$off').and.callThrough();
model.dispose();
expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything());
});
});
});
/* global monaco */
import monacoLoader from 'ee/ide/monaco_loader';
import editor from 'ee/ide/lib/editor';
import DecorationsController from 'ee/ide/lib/decorations/controller';
import Model from 'ee/ide/lib/common/model';
import { file } from '../../helpers';
describe('Multi-file editor library decorations controller', () => {
let editorInstance;
let controller;
let model;
beforeEach((done) => {
monacoLoader(['vs/editor/editor.main'], () => {
editorInstance = editor.create(monaco);
editorInstance.createInstance(document.createElement('div'));
controller = new DecorationsController(editorInstance);
model = new Model(monaco, file('path'));
done();
});
});
afterEach(() => {
model.dispose();
editorInstance.dispose();
controller.dispose();
});
describe('getAllDecorationsForModel', () => {
it('returns empty array when no decorations exist for model', () => {
const decorations = controller.getAllDecorationsForModel(model);
expect(decorations).toEqual([]);
});
it('returns decorations by model URL', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
const decorations = controller.getAllDecorationsForModel(model);
expect(decorations[0]).toEqual({ decoration: 'decorationValue' });
});
});
describe('addDecorations', () => {
it('caches decorations in a new map', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
expect(controller.decorations.size).toBe(1);
});
it('does not create new cache model', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]);
expect(controller.decorations.size).toBe(1);
});
it('caches decorations by model URL', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
expect(controller.decorations.size).toBe(1);
expect(controller.decorations.keys().next().value).toBe('path');
});
it('calls decorate method', () => {
spyOn(controller, 'decorate');
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
expect(controller.decorate).toHaveBeenCalled();
});
});
describe('decorate', () => {
it('sets decorations on editor instance', () => {
spyOn(controller.editor.instance, 'deltaDecorations');
controller.decorate(model);
expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []);
});
it('caches decorations', () => {
spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
controller.decorate(model);
expect(controller.editorDecorations.size).toBe(1);
});
it('caches decorations by model URL', () => {
spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
controller.decorate(model);
expect(controller.editorDecorations.keys().next().value).toBe('path');
});
});
describe('dispose', () => {
it('clears cached decorations', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
controller.dispose();
expect(controller.decorations.size).toBe(0);
});
it('clears cached editorDecorations', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
controller.dispose();
expect(controller.editorDecorations.size).toBe(0);
});
});
});
/* global monaco */
import monacoLoader from 'ee/ide/monaco_loader';
import editor from 'ee/ide/lib/editor';
import ModelManager from 'ee/ide/lib/common/model_manager';
import DecorationsController from 'ee/ide/lib/decorations/controller';
import DirtyDiffController, { getDiffChangeType, getDecorator } from 'ee/ide/lib/diff/controller';
import { computeDiff } from 'ee/ide/lib/diff/diff';
import { file } from '../../helpers';
describe('Multi-file editor library dirty diff controller', () => {
let editorInstance;
let controller;
let modelManager;
let decorationsController;
let model;
beforeEach((done) => {
monacoLoader(['vs/editor/editor.main'], () => {
editorInstance = editor.create(monaco);
editorInstance.createInstance(document.createElement('div'));
modelManager = new ModelManager(monaco);
decorationsController = new DecorationsController(editorInstance);
model = modelManager.addModel(file('path'));
controller = new DirtyDiffController(modelManager, decorationsController);
done();
});
});
afterEach(() => {
controller.dispose();
model.dispose();
decorationsController.dispose();
editorInstance.dispose();
});
describe('getDiffChangeType', () => {
['added', 'removed', 'modified'].forEach((type) => {
it(`returns ${type}`, () => {
const change = {
[type]: true,
};
expect(getDiffChangeType(change)).toBe(type);
});
});
});
describe('getDecorator', () => {
['added', 'removed', 'modified'].forEach((type) => {
it(`returns with linesDecorationsClassName for ${type}`, () => {
const change = {
[type]: true,
};
expect(
getDecorator(change).options.linesDecorationsClassName,
).toBe(`dirty-diff dirty-diff-${type}`);
});
it('returns with line numbers', () => {
const change = {
lineNumber: 1,
endLineNumber: 2,
[type]: true,
};
const range = getDecorator(change).range;
expect(range.startLineNumber).toBe(1);
expect(range.endLineNumber).toBe(2);
expect(range.startColumn).toBe(1);
expect(range.endColumn).toBe(1);
});
});
});
describe('attachModel', () => {
it('adds change event callback', () => {
spyOn(model, 'onChange');
controller.attachModel(model);
expect(model.onChange).toHaveBeenCalled();
});
it('calls throttledComputeDiff on change', () => {
spyOn(controller, 'throttledComputeDiff');
controller.attachModel(model);
model.getModel().setValue('123');
expect(controller.throttledComputeDiff).toHaveBeenCalled();
});
});
describe('computeDiff', () => {
it('posts to worker', () => {
spyOn(controller.dirtyDiffWorker, 'postMessage');
controller.computeDiff(model);
expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({
path: model.path,
originalContent: '',
newContent: '',
});
});
});
describe('reDecorate', () => {
it('calls decorations controller decorate', () => {
spyOn(controller.decorationsController, 'decorate');
controller.reDecorate(model);
expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model);
});
});
describe('decorate', () => {
it('adds decorations into decorations controller', () => {
spyOn(controller.decorationsController, 'addDecorations');
controller.decorate({ data: { changes: [], path: 'path' } });
expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything());
});
it('adds decorations into editor', () => {
const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations');
controller.decorate({ data: { changes: computeDiff('123', '1234'), path: 'path' } });
expect(spy).toHaveBeenCalledWith([], [{
range: new monaco.Range(
1, 1, 1, 1,
),
options: {
isWholeLine: true,
linesDecorationsClassName: 'dirty-diff dirty-diff-modified',
},
}]);
});
});
describe('dispose', () => {
it('calls disposable dispose', () => {
spyOn(controller.disposable, 'dispose').and.callThrough();
controller.dispose();
expect(controller.disposable.dispose).toHaveBeenCalled();
});
it('terminates worker', () => {
spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough();
controller.dispose();
expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled();
});
it('removes worker event listener', () => {
spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough();
controller.dispose();
expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith('message', jasmine.anything());
});
});
});
import { computeDiff } from 'ee/ide/lib/diff/diff';
describe('Multi-file editor library diff calculator', () => {
describe('computeDiff', () => {
it('returns empty array if no changes', () => {
const diff = computeDiff('123', '123');
expect(diff).toEqual([]);
});
describe('modified', () => {
it('', () => {
const diff = computeDiff('123', '1234')[0];
expect(diff.added).toBeTruthy();
expect(diff.modified).toBeTruthy();
expect(diff.removed).toBeUndefined();
});
it('', () => {
const diff = computeDiff('123\n123\n123', '123\n1234\n123')[0];
expect(diff.added).toBeTruthy();
expect(diff.modified).toBeTruthy();
expect(diff.removed).toBeUndefined();
expect(diff.lineNumber).toBe(2);
});
});
describe('added', () => {
it('', () => {
const diff = computeDiff('123', '123\n123')[0];
expect(diff.added).toBeTruthy();
expect(diff.modified).toBeUndefined();
expect(diff.removed).toBeUndefined();
});
it('', () => {
const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123')[0];
expect(diff.added).toBeTruthy();
expect(diff.modified).toBeUndefined();
expect(diff.removed).toBeUndefined();
expect(diff.lineNumber).toBe(3);
});
});
describe('removed', () => {
it('', () => {
const diff = computeDiff('123', '')[0];
expect(diff.added).toBeUndefined();
expect(diff.modified).toBeUndefined();
expect(diff.removed).toBeTruthy();
});
it('', () => {
const diff = computeDiff('123\n123\n123', '123\n123')[0];
expect(diff.added).toBeUndefined();
expect(diff.modified).toBeTruthy();
expect(diff.removed).toBeTruthy();
expect(diff.lineNumber).toBe(2);
});
});
it('includes line number of change', () => {
const diff = computeDiff('123', '')[0];
expect(diff.lineNumber).toBe(1);
});
it('includes end line number of change', () => {
const diff = computeDiff('123', '')[0];
expect(diff.endLineNumber).toBe(1);
});
});
});
import editorOptions from 'ee/ide/lib/editor_options';
describe('Multi-file editor library editor options', () => {
it('returns an array', () => {
expect(editorOptions).toEqual(jasmine.any(Array));
});
it('contains readOnly option', () => {
expect(editorOptions[0].readOnly).toBeDefined();
});
});
/* global monaco */
import monacoLoader from 'ee/ide/monaco_loader';
import editor from 'ee/ide/lib/editor';
import { file } from '../helpers';
describe('Multi-file editor library', () => {
let instance;
let el;
let holder;
beforeEach(done => {
el = document.createElement('div');
holder = document.createElement('div');
el.appendChild(holder);
document.body.appendChild(el);
monacoLoader(['vs/editor/editor.main'], () => {
instance = editor.create(monaco);
done();
});
});
afterEach(() => {
instance.dispose();
el.remove();
});
it('creates instance of editor', () => {
expect(editor.editorInstance).not.toBeNull();
});
it('creates instance returns cached instance', () => {
expect(editor.create(monaco)).toEqual(instance);
});
describe('createInstance', () => {
it('creates editor instance', () => {
spyOn(instance.monaco.editor, 'create').and.callThrough();
instance.createInstance(holder);
expect(instance.monaco.editor.create).toHaveBeenCalled();
});
it('creates dirty diff controller', () => {
instance.createInstance(holder);
expect(instance.dirtyDiffController).not.toBeNull();
});
it('creates model manager', () => {
instance.createInstance(holder);
expect(instance.modelManager).not.toBeNull();
});
});
describe('createDiffInstance', () => {
it('creates editor instance', () => {
spyOn(instance.monaco.editor, 'createDiffEditor').and.callThrough();
instance.createDiffInstance(holder);
expect(instance.monaco.editor.createDiffEditor).toHaveBeenCalledWith(
holder,
{
model: null,
contextmenu: true,
minimap: {
enabled: false,
},
readOnly: true,
scrollBeyondLastLine: false,
},
);
});
});
describe('createModel', () => {
it('calls model manager addModel', () => {
spyOn(instance.modelManager, 'addModel');
instance.createModel('FILE');
expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE');
});
});
describe('attachModel', () => {
let model;
beforeEach(() => {
instance.createInstance(document.createElement('div'));
model = instance.createModel(file());
});
it('sets the current model on the instance', () => {
instance.attachModel(model);
expect(instance.currentModel).toBe(model);
});
it('attaches the model to the current instance', () => {
spyOn(instance.instance, 'setModel');
instance.attachModel(model);
expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel());
});
it('sets original & modified when diff editor', () => {
spyOn(instance.instance, 'getEditorType').and.returnValue(
'vs.editor.IDiffEditor',
);
spyOn(instance.instance, 'setModel');
instance.attachModel(model);
expect(instance.instance.setModel).toHaveBeenCalledWith({
original: model.getOriginalModel(),
modified: model.getModel(),
});
});
it('attaches the model to the dirty diff controller', () => {
spyOn(instance.dirtyDiffController, 'attachModel');
instance.attachModel(model);
expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(
model,
);
});
it('re-decorates with the dirty diff controller', () => {
spyOn(instance.dirtyDiffController, 'reDecorate');
instance.attachModel(model);
expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(
model,
);
});
});
describe('clearEditor', () => {
it('resets the editor model', () => {
instance.createInstance(document.createElement('div'));
spyOn(instance.instance, 'setModel');
instance.clearEditor();
expect(instance.instance.setModel).toHaveBeenCalledWith(null);
});
});
describe('dispose', () => {
it('calls disposble dispose method', () => {
spyOn(instance.disposable, 'dispose').and.callThrough();
instance.dispose();
expect(instance.disposable.dispose).toHaveBeenCalled();
});
it('resets instance', () => {
instance.createInstance(document.createElement('div'));
expect(instance.instance).not.toBeNull();
instance.dispose();
expect(instance.instance).toBeNull();
});
it('does not dispose modelManager', () => {
spyOn(instance.modelManager, 'dispose');
instance.dispose();
expect(instance.modelManager.dispose).not.toHaveBeenCalled();
});
it('does not dispose decorationsController', () => {
spyOn(instance.decorationsController, 'dispose');
instance.dispose();
expect(instance.decorationsController.dispose).not.toHaveBeenCalled();
});
});
});
import monacoContext from 'monaco-editor/dev/vs/loader';
import monacoLoader from 'ee/ide/monaco_loader';
describe('MonacoLoader', () => {
it('calls require.config and exports require', () => {
expect(monacoContext.require.getConfig()).toEqual(jasmine.objectContaining({
paths: {
vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase
},
}));
expect(monacoLoader).toBe(monacoContext.require);
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import service from 'ee/ide/services';
import router from 'ee/ide/ide_router';
import eventHub from 'ee/ide/eventhub';
import { file, resetStore } from '../../helpers';
describe('Multi-file store file actions', () => {
beforeEach(() => {
spyOn(router, 'push');
});
afterEach(() => {
resetStore(store);
});
describe('closeFile', () => {
let localFile;
beforeEach(() => {
localFile = file('testFile');
localFile.active = true;
localFile.opened = true;
localFile.parentTreeUrl = 'parentTreeUrl';
store.state.openFiles.push(localFile);
store.state.entries[localFile.path] = localFile;
});
it('closes open files', done => {
store
.dispatch('closeFile', localFile.path)
.then(() => {
expect(localFile.opened).toBeFalsy();
expect(localFile.active).toBeFalsy();
expect(store.state.openFiles.length).toBe(0);
done();
})
.catch(done.fail);
});
it('closes file even if file has changes', done => {
store.state.changedFiles.push(localFile);
store
.dispatch('closeFile', localFile.path)
.then(Vue.nextTick)
.then(() => {
expect(store.state.openFiles.length).toBe(0);
expect(store.state.changedFiles.length).toBe(1);
done();
})
.catch(done.fail);
});
it('closes file & opens next available file', done => {
const f = {
...file('newOpenFile'),
url: '/newOpenFile',
};
store.state.openFiles.push(f);
store.state.entries[f.path] = f;
store
.dispatch('closeFile', localFile.path)
.then(Vue.nextTick)
.then(() => {
expect(router.push).toHaveBeenCalledWith(`/project${f.url}`);
done();
})
.catch(done.fail);
});
});
describe('setFileActive', () => {
let localFile;
let scrollToTabSpy;
let oldScrollToTab;
beforeEach(() => {
scrollToTabSpy = jasmine.createSpy('scrollToTab');
oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line
store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line
localFile = file('setThisActive');
store.state.entries[localFile.path] = localFile;
});
afterEach(() => {
store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line
});
it('calls scrollToTab', done => {
store
.dispatch('setFileActive', localFile.path)
.then(() => {
expect(scrollToTabSpy).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('sets the file active', done => {
store
.dispatch('setFileActive', localFile.path)
.then(() => {
expect(localFile.active).toBeTruthy();
done();
})
.catch(done.fail);
});
it('returns early if file is already active', done => {
localFile.active = true;
store
.dispatch('setFileActive', localFile.path)
.then(() => {
expect(scrollToTabSpy).not.toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('sets current active file to not active', done => {
const f = file('newActive');
store.state.entries[f.path] = f;
localFile.active = true;
store.state.openFiles.push(localFile);
store
.dispatch('setFileActive', f.path)
.then(() => {
expect(localFile.active).toBeFalsy();
done();
})
.catch(done.fail);
});
it('resets location.hash for line highlighting', done => {
location.hash = 'test';
store
.dispatch('setFileActive', localFile.path)
.then(() => {
expect(location.hash).not.toBe('test');
done();
})
.catch(done.fail);
});
});
describe('getFileData', () => {
let localFile;
beforeEach(() => {
spyOn(service, 'getFileData').and.returnValue(
Promise.resolve({
headers: {
'page-title': 'testing getFileData',
},
json: () =>
Promise.resolve({
blame_path: 'blame_path',
commits_path: 'commits_path',
permalink: 'permalink',
raw_path: 'raw_path',
binary: false,
html: '123',
render_error: '',
}),
}),
);
localFile = file(`newCreate-${Math.random()}`);
localFile.url = 'getFileDataURL';
store.state.entries[localFile.path] = localFile;
});
it('calls the service', done => {
store
.dispatch('getFileData', localFile)
.then(() => {
expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL');
done();
})
.catch(done.fail);
});
it('sets the file data', done => {
store
.dispatch('getFileData', localFile)
.then(() => {
expect(localFile.blamePath).toBe('blame_path');
done();
})
.catch(done.fail);
});
it('sets document title', done => {
store
.dispatch('getFileData', localFile)
.then(() => {
expect(document.title).toBe('testing getFileData');
done();
})
.catch(done.fail);
});
it('sets the file as active', done => {
store
.dispatch('getFileData', localFile)
.then(() => {
expect(localFile.active).toBeTruthy();
done();
})
.catch(done.fail);
});
it('adds the file to open files', done => {
store
.dispatch('getFileData', localFile)
.then(() => {
expect(store.state.openFiles.length).toBe(1);
expect(store.state.openFiles[0].name).toBe(localFile.name);
done();
})
.catch(done.fail);
});
});
describe('getRawFileData', () => {
let tmpFile;
beforeEach(() => {
spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw'));
tmpFile = file('tmpFile');
store.state.entries[tmpFile.path] = tmpFile;
});
it('calls getRawFileData service method', done => {
store
.dispatch('getRawFileData', tmpFile)
.then(() => {
expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile);
done();
})
.catch(done.fail);
});
it('updates file raw data', done => {
store
.dispatch('getRawFileData', tmpFile)
.then(() => {
expect(tmpFile.raw).toBe('raw');
done();
})
.catch(done.fail);
});
});
describe('changeFileContent', () => {
let tmpFile;
beforeEach(() => {
tmpFile = file('tmpFile');
store.state.entries[tmpFile.path] = tmpFile;
});
it('updates file content', done => {
store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content',
})
.then(() => {
expect(tmpFile.content).toBe('content');
done();
})
.catch(done.fail);
});
it('adds file into changedFiles array', done => {
store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content',
})
.then(() => {
expect(store.state.changedFiles.length).toBe(1);
done();
})
.catch(done.fail);
});
it('adds file once into changedFiles array', done => {
store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content',
})
.then(() =>
store.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content 123',
}),
)
.then(() => {
expect(store.state.changedFiles.length).toBe(1);
done();
})
.catch(done.fail);
});
it('removes file from changedFiles array if not changed', done => {
store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content',
})
.then(() =>
store.dispatch('changeFileContent', {
path: tmpFile.path,
content: '',
}),
)
.then(() => {
expect(store.state.changedFiles.length).toBe(0);
done();
})
.catch(done.fail);
});
});
describe('discardFileChanges', () => {
let tmpFile;
beforeEach(() => {
spyOn(eventHub, '$on');
tmpFile = file();
tmpFile.content = 'testing';
store.state.changedFiles.push(tmpFile);
store.state.entries[tmpFile.path] = tmpFile;
});
it('resets file content', done => {
store
.dispatch('discardFileChanges', tmpFile.path)
.then(() => {
expect(tmpFile.content).not.toBe('testing');
done();
})
.catch(done.fail);
});
it('removes file from changedFiles array', done => {
store
.dispatch('discardFileChanges', tmpFile.path)
.then(() => {
expect(store.state.changedFiles.length).toBe(0);
done();
})
.catch(done.fail);
});
it('closes temp file', done => {
tmpFile.tempFile = true;
tmpFile.opened = true;
store
.dispatch('discardFileChanges', tmpFile.path)
.then(() => {
expect(tmpFile.opened).toBeFalsy();
done();
})
.catch(done.fail);
});
it('does not re-open a closed temp file', done => {
tmpFile.tempFile = true;
expect(tmpFile.opened).toBeFalsy();
store
.dispatch('discardFileChanges', tmpFile.path)
.then(() => {
expect(tmpFile.opened).toBeFalsy();
done();
})
.catch(done.fail);
});
});
});
import Vue from 'vue';
import store from 'ee/ide/stores';
import service from 'ee/ide/services';
import router from 'ee/ide/ide_router';
import { file, resetStore } from '../../helpers';
describe('Multi-file store tree actions', () => {
let projectTree;
const basicCallParameters = {
endpoint: 'rootEndpoint',
projectId: 'abcproject',
branch: 'master',
branchId: 'master',
};
beforeEach(() => {
spyOn(router, 'push');
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
web_url: '',
branches: {
master: {
workingReference: '1',
},
},
};
});
afterEach(() => {
resetStore(store);
});
describe('getFiles', () => {
beforeEach(() => {
spyOn(service, 'getFiles').and.returnValue(Promise.resolve({
json: () => Promise.resolve([
'file.txt',
'folder/fileinfolder.js',
'folder/subfolder/fileinsubfolder.js',
]),
}));
});
it('calls service getFiles', (done) => {
store.dispatch('getFiles', basicCallParameters)
.then(() => {
expect(service.getFiles).toHaveBeenCalledWith('', 'master');
done();
}).catch(done.fail);
});
it('adds data into tree', (done) => {
store.dispatch('getFiles', basicCallParameters)
.then(() => {
projectTree = store.state.trees['abcproject/master'];
expect(projectTree.tree.length).toBe(2);
expect(projectTree.tree[0].type).toBe('tree');
expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js');
expect(projectTree.tree[1].type).toBe('blob');
expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob');
expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js');
done();
}).catch(done.fail);
});
});
describe('toggleTreeOpen', () => {
let tree;
beforeEach(() => {
tree = file('testing', '1', 'tree');
store.state.entries[tree.path] = tree;
});
it('toggles the tree open', (done) => {
store.dispatch('toggleTreeOpen', tree.path).then(() => {
expect(tree.opened).toBeTruthy();
done();
}).catch(done.fail);
});
});
describe('getLastCommitData', () => {
beforeEach(() => {
spyOn(service, 'getTreeLastCommit').and.returnValue(Promise.resolve({
headers: {
'more-logs-url': null,
},
json: () => Promise.resolve([{
type: 'tree',
file_name: 'testing',
commit: {
message: 'commit message',
authored_date: '123',
},
}]),
}));
store.state.trees['abcproject/mybranch'] = {
tree: [],
};
projectTree = store.state.trees['abcproject/mybranch'];
projectTree.tree.push(file('testing', '1', 'tree'));
projectTree.lastCommitPath = 'lastcommitpath';
});
it('calls service with lastCommitPath', (done) => {
store.dispatch('getLastCommitData', projectTree)
.then(() => {
expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath');
done();
}).catch(done.fail);
});
it('updates trees last commit data', (done) => {
store.dispatch('getLastCommitData', projectTree)
.then(Vue.nextTick)
.then(() => {
expect(projectTree.tree[0].lastCommit.message).toBe('commit message');
done();
}).catch(done.fail);
});
it('does not update entry if not found', (done) => {
projectTree.tree[0].name = 'a';
store.dispatch('getLastCommitData', projectTree)
.then(Vue.nextTick)
.then(() => {
expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message');
done();
}).catch(done.fail);
});
});
});
import * as urlUtils from '~/lib/utils/url_utility';
import store from 'ee/ide/stores';
import router from 'ee/ide/ide_router';
import { resetStore, file } from '../helpers';
describe('Multi-file store actions', () => {
beforeEach(() => {
spyOn(router, 'push');
});
afterEach(() => {
resetStore(store);
});
describe('redirectToUrl', () => {
it('calls visitUrl', done => {
spyOn(urlUtils, 'visitUrl');
store
.dispatch('redirectToUrl', 'test')
.then(() => {
expect(urlUtils.visitUrl).toHaveBeenCalledWith('test');
done();
})
.catch(done.fail);
});
});
describe('setInitialData', () => {
it('commits initial data', done => {
store
.dispatch('setInitialData', { canCommit: true })
.then(() => {
expect(store.state.canCommit).toBeTruthy();
done();
})
.catch(done.fail);
});
});
describe('discardAllChanges', () => {
beforeEach(() => {
const f = file('discardAll');
f.changed = true;
store.state.openFiles.push(f);
store.state.changedFiles.push(f);
store.state.entries[f.path] = f;
});
it('discards changes in file', done => {
store
.dispatch('discardAllChanges')
.then(() => {
expect(store.state.openFiles.changed).toBeFalsy();
})
.then(done)
.catch(done.fail);
});
it('removes all files from changedFiles state', done => {
store
.dispatch('discardAllChanges')
.then(() => {
expect(store.state.changedFiles.length).toBe(0);
expect(store.state.openFiles.length).toBe(1);
})
.then(done)
.catch(done.fail);
});
});
describe('closeAllFiles', () => {
beforeEach(() => {
const f = file('closeAll');
store.state.openFiles.push(f);
store.state.openFiles[0].opened = true;
store.state.entries[f.path] = f;
});
it('closes all open files', done => {
store
.dispatch('closeAllFiles')
.then(() => {
expect(store.state.openFiles.length).toBe(0);
done();
})
.catch(done.fail);
});
});
describe('createTempEntry', () => {
beforeEach(() => {
document.body.innerHTML += '<div class="flash-container"></div>';
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'mybranch';
store.state.trees['abcproject/mybranch'] = {
tree: [],
};
store.state.projects.abcproject = {
web_url: '',
};
});
afterEach(() => {
document.querySelector('.flash-container').remove();
});
describe('tree', () => {
it('creates temp tree', done => {
store
.dispatch('createTempEntry', {
branchId: store.state.currentBranchId,
name: 'test',
type: 'tree',
})
.then(() => {
const entry = store.state.entries.test;
expect(entry).not.toBeNull();
expect(entry.type).toBe('tree');
done();
})
.catch(done.fail);
});
it('creates new folder inside another tree', done => {
const tree = {
type: 'tree',
name: 'testing',
path: 'testing',
tree: [],
};
store.state.entries[tree.path] = tree;
store
.dispatch('createTempEntry', {
branchId: store.state.currentBranchId,
name: 'testing/test',
type: 'tree',
})
.then(() => {
expect(tree.tree[0].tempFile).toBeTruthy();
expect(tree.tree[0].name).toBe('test');
expect(tree.tree[0].type).toBe('tree');
done();
})
.catch(done.fail);
});
it('does not create new tree if already exists', done => {
const tree = {
type: 'tree',
path: 'testing',
tempFile: false,
tree: [],
};
store.state.entries[tree.path] = tree;
store
.dispatch('createTempEntry', {
branchId: store.state.currentBranchId,
name: 'testing',
type: 'tree',
})
.then(() => {
expect(store.state.entries[tree.path].tempFile).toEqual(false);
expect(document.querySelector('.flash-alert')).not.toBeNull();
done();
})
.catch(done.fail);
});
});
describe('blob', () => {
it('creates temp file', done => {
store
.dispatch('createTempEntry', {
name: 'test',
branchId: 'mybranch',
type: 'blob',
})
.then(f => {
expect(f.tempFile).toBeTruthy();
expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(
1,
);
done();
})
.catch(done.fail);
});
it('adds tmp file to open files', done => {
store
.dispatch('createTempEntry', {
name: 'test',
branchId: 'mybranch',
type: 'blob',
})
.then(f => {
expect(store.state.openFiles.length).toBe(1);
expect(store.state.openFiles[0].name).toBe(f.name);
done();
})
.catch(done.fail);
});
it('adds tmp file to changed files', done => {
store
.dispatch('createTempEntry', {
name: 'test',
branchId: 'mybranch',
type: 'blob',
})
.then(f => {
expect(store.state.changedFiles.length).toBe(1);
expect(store.state.changedFiles[0].name).toBe(f.name);
done();
})
.catch(done.fail);
});
it('sets tmp file as active', done => {
store
.dispatch('createTempEntry', {
name: 'test',
branchId: 'mybranch',
type: 'blob',
})
.then(f => {
expect(f.active).toBeTruthy();
done();
})
.catch(done.fail);
});
it('creates flash message if file already exists', done => {
const f = file('test', '1', 'blob');
store.state.trees['abcproject/mybranch'].tree = [f];
store.state.entries[f.path] = f;
store
.dispatch('createTempEntry', {
name: 'test',
branchId: 'mybranch',
type: 'blob',
})
.then(() => {
expect(document.querySelector('.flash-alert')).not.toBeNull();
done();
})
.catch(done.fail);
});
});
});
describe('popHistoryState', () => {});
describe('scrollToTab', () => {
it('focuses the current active element', done => {
document.body.innerHTML +=
'<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>';
const el = document.querySelector('.repo-tab');
spyOn(el, 'focus');
store
.dispatch('scrollToTab')
.then(() => {
setTimeout(() => {
expect(el.focus).toHaveBeenCalled();
document.getElementById('tabs').remove();
done();
});
})
.catch(done.fail);
});
});
describe('updateViewer', () => {
it('updates viewer state', done => {
store
.dispatch('updateViewer', 'diff')
.then(() => {
expect(store.state.viewer).toBe('diff');
})
.then(done)
.catch(done.fail);
});
});
});
import * as getters from 'ee/ide/stores/getters';
import state from 'ee/ide/stores/state';
import { file } from '../helpers';
describe('Multi-file store getters', () => {
let localState;
beforeEach(() => {
localState = state();
});
describe('activeFile', () => {
it('returns the current active file', () => {
localState.openFiles.push(file());
localState.openFiles.push(file('active'));
localState.openFiles[1].active = true;
expect(getters.activeFile(localState).name).toBe('active');
});
it('returns undefined if no active files are found', () => {
localState.openFiles.push(file());
localState.openFiles.push(file('active'));
expect(getters.activeFile(localState)).toBeNull();
});
});
describe('modifiedFiles', () => {
it('returns a list of modified files', () => {
localState.openFiles.push(file());
localState.changedFiles.push(file('changed'));
localState.changedFiles[0].changed = true;
const modifiedFiles = getters.modifiedFiles(localState);
expect(modifiedFiles.length).toBe(1);
expect(modifiedFiles[0].name).toBe('changed');
});
});
describe('addedFiles', () => {
it('returns a list of added files', () => {
localState.openFiles.push(file());
localState.changedFiles.push(file('added'));
localState.changedFiles[0].changed = true;
localState.changedFiles[0].tempFile = true;
const modifiedFiles = getters.addedFiles(localState);
expect(modifiedFiles.length).toBe(1);
expect(modifiedFiles[0].name).toBe('added');
});
});
});
import store from 'ee/ide/stores';
import service from 'ee/ide/services';
import router from 'ee/ide/ide_router';
import * as urlUtils from '~/lib/utils/url_utility';
import eventHub from 'ee/ide/eventhub';
import * as consts from 'ee/ide/stores/modules/commit/constants';
import { resetStore, file } from 'spec/ide/helpers';
describe('IDE commit module actions', () => {
beforeEach(() => {
spyOn(router, 'push');
});
afterEach(() => {
resetStore(store);
});
describe('updateCommitMessage', () => {
it('updates store with new commit message', (done) => {
store.dispatch('commit/updateCommitMessage', 'testing')
.then(() => {
expect(store.state.commit.commitMessage).toBe('testing');
})
.then(done)
.catch(done.fail);
});
});
describe('discardDraft', () => {
it('resets commit message to blank', (done) => {
store.state.commit.commitMessage = 'testing';
store.dispatch('commit/discardDraft')
.then(() => {
expect(store.state.commit.commitMessage).not.toBe('testing');
})
.then(done)
.catch(done.fail);
});
});
describe('updateCommitAction', () => {
it('updates store with new commit action', (done) => {
store.dispatch('commit/updateCommitAction', '1')
.then(() => {
expect(store.state.commit.commitAction).toBe('1');
})
.then(done)
.catch(done.fail);
});
});
describe('updateBranchName', () => {
it('updates store with new branch name', (done) => {
store.dispatch('commit/updateBranchName', 'branch-name')
.then(() => {
expect(store.state.commit.newBranchName).toBe('branch-name');
})
.then(done)
.catch(done.fail);
});
});
describe('setLastCommitMessage', () => {
beforeEach(() => {
Object.assign(store.state, {
currentProjectId: 'abcproject',
projects: {
abcproject: {
web_url: 'http://testing',
},
},
});
});
it('updates commit message with short_id', (done) => {
store.dispatch('commit/setLastCommitMessage', { short_id: '123' })
.then(() => {
expect(store.state.lastCommitMsg).toContain(
'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a>',
);
})
.then(done)
.catch(done.fail);
});
it('updates commit message with stats', (done) => {
store.dispatch('commit/setLastCommitMessage', {
short_id: '123',
stats: {
additions: '1',
deletions: '2',
},
})
.then(() => {
expect(store.state.lastCommitMsg).toBe('Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.');
})
.then(done)
.catch(done.fail);
});
});
describe('checkCommitStatus', () => {
beforeEach(() => {
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
branches: {
master: {
workingReference: '1',
},
},
};
});
it('calls service', (done) => {
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
data: {
commit: { id: '123' },
},
}));
store.dispatch('commit/checkCommitStatus')
.then(() => {
expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master');
done();
})
.catch(done.fail);
});
it('returns true if current ref does not equal returned ID', (done) => {
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
data: {
commit: { id: '123' },
},
}));
store.dispatch('commit/checkCommitStatus')
.then((val) => {
expect(val).toBeTruthy();
done();
})
.catch(done.fail);
});
it('returns false if current ref equals returned ID', (done) => {
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
data: {
commit: { id: '1' },
},
}));
store.dispatch('commit/checkCommitStatus')
.then((val) => {
expect(val).toBeFalsy();
done();
})
.catch(done.fail);
});
});
describe('updateFilesAfterCommit', () => {
const data = {
id: '123',
message: 'testing commit message',
committed_date: '123',
committer_name: 'root',
};
const branch = 'master';
let f;
beforeEach(() => {
spyOn(eventHub, '$emit');
f = file('changedFile');
Object.assign(f, {
active: true,
changed: true,
content: 'file content',
});
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
web_url: 'web_url',
branches: {
master: {
workingReference: '',
},
},
};
store.state.changedFiles.push(f, {
...file('changedFile2'),
changed: true,
});
store.state.openFiles = store.state.changedFiles;
store.state.changedFiles.forEach((changedFile) => {
store.state.entries[changedFile.path] = changedFile;
});
});
it('updates stores working reference', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(
store.state.projects.abcproject.branches.master.workingReference,
).toBe(data.id);
})
.then(done)
.catch(done.fail);
});
it('resets all files changed status', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
store.state.openFiles.forEach((entry) => {
expect(entry.changed).toBeFalsy();
});
})
.then(done)
.catch(done.fail);
});
it('removes all changed files', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(store.state.changedFiles.length).toBe(0);
})
.then(done)
.catch(done.fail);
});
it('sets files commit data', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(f.lastCommit.message).toBe(data.message);
})
.then(done)
.catch(done.fail);
});
it('updates raw content for changed file', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(f.raw).toBe(f.content);
})
.then(done)
.catch(done.fail);
});
it('emits changed event for file', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.path}`, f.content);
})
.then(done)
.catch(done.fail);
});
it('pushes route to new branch if commitAction is new branch', (done) => {
store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH;
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(router.push).toHaveBeenCalledWith(
`/project/abcproject/blob/master/${f.path}`,
);
})
.then(done)
.catch(done.fail);
});
it('resets stores commit actions', (done) => {
store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH;
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(store.state.commit.commitAction).not.toBe(consts.COMMIT_TO_NEW_BRANCH);
})
.then(done)
.catch(done.fail);
});
});
describe('commitChanges', () => {
beforeEach(() => {
spyOn(urlUtils, 'visitUrl');
document.body.innerHTML += '<div class="flash-container"></div>';
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
web_url: 'webUrl',
branches: {
master: {
workingReference: '1',
},
},
};
store.state.changedFiles.push(file('changed'));
store.state.changedFiles[0].active = true;
store.state.openFiles = store.state.changedFiles;
store.state.openFiles.forEach((f) => {
store.state.entries[f.path] = f;
});
store.state.commit.commitAction = '2';
store.state.commit.commitMessage = 'testing 123';
});
afterEach(() => {
document.querySelector('.flash-container').remove();
});
describe('success', () => {
beforeEach(() => {
spyOn(service, 'commit').and.returnValue(Promise.resolve({
data: {
id: '123456',
short_id: '123',
message: 'test message',
committed_date: 'date',
stats: {
additions: '1',
deletions: '2',
},
},
}));
});
it('calls service', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
expect(service.commit).toHaveBeenCalledWith('abcproject', {
branch: jasmine.anything(),
commit_message: 'testing 123',
actions: [{
action: 'update',
file_path: jasmine.anything(),
content: jasmine.anything(),
encoding: jasmine.anything(),
}],
start_branch: 'master',
});
done();
}).catch(done.fail);
});
it('pushes router to new route', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
expect(router.push).toHaveBeenCalledWith(
`/project/${store.state.currentProjectId}/blob/${store.getters['commit/newBranchName']}/changed`,
);
done();
}).catch(done.fail);
});
it('sets last Commit Msg', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
expect(store.state.lastCommitMsg).toBe(
'Your changes have been committed. Commit <a href="webUrl/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
);
done();
}).catch(done.fail);
});
it('adds commit data to changed files', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
expect(store.state.openFiles[0].lastCommit.message).toBe('test message');
done();
}).catch(done.fail);
});
it('redirects to new merge request page', (done) => {
spyOn(eventHub, '$on');
store.state.commit.commitAction = '3';
store.dispatch('commit/commitChanges')
.then(() => {
expect(urlUtils.visitUrl).toHaveBeenCalledWith(
`webUrl/merge_requests/new?merge_request[source_branch]=${store.getters['commit/newBranchName']}&merge_request[target_branch]=master`,
);
done();
}).catch(done.fail);
});
});
describe('failed', () => {
beforeEach(() => {
spyOn(service, 'commit').and.returnValue(Promise.resolve({
data: {
message: 'failed message',
},
}));
});
it('shows failed message', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
const alert = document.querySelector('.flash-container');
expect(alert.textContent.trim()).toBe(
'failed message',
);
done();
}).catch(done.fail);
});
});
});
});
import commitState from 'ee/ide/stores/modules/commit/state';
import * as consts from 'ee/ide/stores/modules/commit/constants';
import * as getters from 'ee/ide/stores/modules/commit/getters';
describe('IDE commit module getters', () => {
let state;
beforeEach(() => {
state = commitState();
});
describe('discardDraftButtonDisabled', () => {
it('returns true when commitMessage is empty', () => {
expect(getters.discardDraftButtonDisabled(state)).toBeTruthy();
});
it('returns false when commitMessage is not empty & loading is false', () => {
state.commitMessage = 'test';
state.submitCommitLoading = false;
expect(getters.discardDraftButtonDisabled(state)).toBeFalsy();
});
it('returns true when commitMessage is not empty & loading is true', () => {
state.commitMessage = 'test';
state.submitCommitLoading = true;
expect(getters.discardDraftButtonDisabled(state)).toBeTruthy();
});
});
describe('commitButtonDisabled', () => {
const localGetters = {
discardDraftButtonDisabled: false,
};
const rootState = {
changedFiles: ['a'],
};
it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeFalsy();
});
it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => {
rootState.changedFiles.length = 0;
expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy();
});
it('returns true when discardDraftButtonDisabled is true', () => {
localGetters.discardDraftButtonDisabled = true;
expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy();
});
it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
localGetters.discardDraftButtonDisabled = false;
rootState.changedFiles.length = 0;
expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy();
});
});
describe('newBranchName', () => {
it('includes username, currentBranchId, patch & random number', () => {
gon.current_username = 'username';
const branch = getters.newBranchName(state, null, { currentBranchId: 'testing' });
expect(branch).toMatch(/username-testing-patch-\d{5}$/);
});
});
describe('branchName', () => {
const rootState = {
currentBranchId: 'master',
};
const localGetters = {
newBranchName: 'newBranchName',
};
beforeEach(() => {
Object.assign(state, {
newBranchName: 'state-newBranchName',
});
});
it('defualts to currentBranchId', () => {
expect(getters.branchName(state, null, rootState)).toBe('master');
});
['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach((type) => {
describe(type, () => {
beforeEach(() => {
Object.assign(state, {
commitAction: consts[type],
});
});
it('uses newBranchName when not empty', () => {
expect(getters.branchName(state, localGetters, rootState)).toBe('state-newBranchName');
});
it('uses getters newBranchName when state newBranchName is empty', () => {
Object.assign(state, {
newBranchName: '',
});
expect(getters.branchName(state, localGetters, rootState)).toBe('newBranchName');
});
});
});
});
});
import commitState from 'ee/ide/stores/modules/commit/state';
import mutations from 'ee/ide/stores/modules/commit/mutations';
describe('IDE commit module mutations', () => {
let state;
beforeEach(() => {
state = commitState();
});
describe('UPDATE_COMMIT_MESSAGE', () => {
it('updates commitMessage', () => {
mutations.UPDATE_COMMIT_MESSAGE(state, 'testing');
expect(state.commitMessage).toBe('testing');
});
});
describe('UPDATE_COMMIT_ACTION', () => {
it('updates commitAction', () => {
mutations.UPDATE_COMMIT_ACTION(state, 'testing');
expect(state.commitAction).toBe('testing');
});
});
describe('UPDATE_NEW_BRANCH_NAME', () => {
it('updates newBranchName', () => {
mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing');
expect(state.newBranchName).toBe('testing');
});
});
describe('UPDATE_LOADING', () => {
it('updates submitCommitLoading', () => {
mutations.UPDATE_LOADING(state, true);
expect(state.submitCommitLoading).toBeTruthy();
});
});
});
import mutations from 'ee/ide/stores/mutations/branch';
import state from 'ee/ide/stores/state';
describe('Multi-file store branch mutations', () => {
let localState;
beforeEach(() => {
localState = state();
});
describe('SET_CURRENT_BRANCH', () => {
it('sets currentBranch', () => {
mutations.SET_CURRENT_BRANCH(localState, 'master');
expect(localState.currentBranchId).toBe('master');
});
});
});
import mutations from 'ee/ide/stores/mutations/file';
import state from 'ee/ide/stores/state';
import { file } from '../../helpers';
describe('Multi-file store file mutations', () => {
let localState;
let localFile;
beforeEach(() => {
localState = state();
localFile = file();
localState.entries[localFile.path] = localFile;
});
describe('SET_FILE_ACTIVE', () => {
it('sets the file active', () => {
mutations.SET_FILE_ACTIVE(localState, {
path: localFile.path,
active: true,
});
expect(localFile.active).toBeTruthy();
});
});
describe('TOGGLE_FILE_OPEN', () => {
beforeEach(() => {
mutations.TOGGLE_FILE_OPEN(localState, localFile.path);
});
it('adds into opened files', () => {
expect(localFile.opened).toBeTruthy();
expect(localState.openFiles.length).toBe(1);
});
it('removes from opened files', () => {
mutations.TOGGLE_FILE_OPEN(localState, localFile.path);
expect(localFile.opened).toBeFalsy();
expect(localState.openFiles.length).toBe(0);
});
});
describe('SET_FILE_DATA', () => {
it('sets extra file data', () => {
mutations.SET_FILE_DATA(localState, {
data: {
blame_path: 'blame',
commits_path: 'commits',
permalink: 'permalink',
raw_path: 'raw',
binary: true,
render_error: 'render_error',
},
file: localFile,
});
expect(localFile.blamePath).toBe('blame');
expect(localFile.commitsPath).toBe('commits');
expect(localFile.permalink).toBe('permalink');
expect(localFile.rawPath).toBe('raw');
expect(localFile.binary).toBeTruthy();
expect(localFile.renderError).toBe('render_error');
});
});
describe('SET_FILE_RAW_DATA', () => {
it('sets raw data', () => {
mutations.SET_FILE_RAW_DATA(localState, {
file: localFile,
raw: 'testing',
});
expect(localFile.raw).toBe('testing');
});
});
describe('UPDATE_FILE_CONTENT', () => {
beforeEach(() => {
localFile.raw = 'test';
});
it('sets content', () => {
mutations.UPDATE_FILE_CONTENT(localState, {
path: localFile.path,
content: 'test',
});
expect(localFile.content).toBe('test');
});
it('sets changed if content does not match raw', () => {
mutations.UPDATE_FILE_CONTENT(localState, {
path: localFile.path,
content: 'testing',
});
expect(localFile.content).toBe('testing');
expect(localFile.changed).toBeTruthy();
});
it('sets changed if file is a temp file', () => {
localFile.tempFile = true;
mutations.UPDATE_FILE_CONTENT(localState, {
path: localFile.path,
content: '',
});
expect(localFile.changed).toBeTruthy();
});
});
describe('DISCARD_FILE_CHANGES', () => {
beforeEach(() => {
localFile.content = 'test';
localFile.changed = true;
});
it('resets content and changed', () => {
mutations.DISCARD_FILE_CHANGES(localState, localFile.path);
expect(localFile.content).toBe('');
expect(localFile.changed).toBeFalsy();
});
});
describe('ADD_FILE_TO_CHANGED', () => {
it('adds file into changed files array', () => {
mutations.ADD_FILE_TO_CHANGED(localState, localFile.path);
expect(localState.changedFiles.length).toBe(1);
});
});
describe('REMOVE_FILE_FROM_CHANGED', () => {
it('removes files from changed files array', () => {
localState.changedFiles.push(localFile);
mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path);
expect(localState.changedFiles.length).toBe(0);
});
});
describe('TOGGLE_FILE_CHANGED', () => {
it('updates file changed status', () => {
mutations.TOGGLE_FILE_CHANGED(localState, {
file: localFile,
changed: true,
});
expect(localFile.changed).toBeTruthy();
});
});
});
import mutations from 'ee/ide/stores/mutations/tree';
import state from 'ee/ide/stores/state';
import { file } from '../../helpers';
describe('Multi-file store tree mutations', () => {
let localState;
let localTree;
beforeEach(() => {
localState = state();
localTree = file();
localState.entries[localTree.path] = localTree;
});
describe('TOGGLE_TREE_OPEN', () => {
it('toggles tree open', () => {
mutations.TOGGLE_TREE_OPEN(localState, localTree.path);
expect(localTree.opened).toBeTruthy();
mutations.TOGGLE_TREE_OPEN(localState, localTree.path);
expect(localTree.opened).toBeFalsy();
});
});
describe('SET_DIRECTORY_DATA', () => {
const data = [{
name: 'tree',
},
{
name: 'submodule',
},
{
name: 'blob',
}];
it('adds directory data', () => {
localState.trees['project/master'] = {
tree: [],
};
mutations.SET_DIRECTORY_DATA(localState, {
data,
treePath: 'project/master',
});
const tree = localState.trees['project/master'];
expect(tree.tree.length).toBe(3);
expect(tree.tree[0].name).toBe('tree');
expect(tree.tree[1].name).toBe('submodule');
expect(tree.tree[2].name).toBe('blob');
});
});
describe('REMOVE_ALL_CHANGES_FILES', () => {
it('removes all files from changedFiles state', () => {
localState.changedFiles.push(file('REMOVE_ALL_CHANGES_FILES'));
mutations.REMOVE_ALL_CHANGES_FILES(localState);
expect(localState.changedFiles.length).toBe(0);
});
});
});
import mutations from 'ee/ide/stores/mutations';
import state from 'ee/ide/stores/state';
import { file } from '../helpers';
describe('Multi-file store mutations', () => {
let localState;
let entry;
beforeEach(() => {
localState = state();
entry = file();
localState.entries[entry.path] = entry;
});
describe('SET_INITIAL_DATA', () => {
it('sets all initial data', () => {
mutations.SET_INITIAL_DATA(localState, {
test: 'test',
});
expect(localState.test).toBe('test');
});
});
describe('TOGGLE_LOADING', () => {
it('toggles loading of entry', () => {
mutations.TOGGLE_LOADING(localState, { entry });
expect(entry.loading).toBeTruthy();
mutations.TOGGLE_LOADING(localState, { entry });
expect(entry.loading).toBeFalsy();
});
it('toggles loading of entry and sets specific value', () => {
mutations.TOGGLE_LOADING(localState, { entry });
expect(entry.loading).toBeTruthy();
mutations.TOGGLE_LOADING(localState, { entry, forceValue: true });
expect(entry.loading).toBeTruthy();
});
});
describe('SET_LEFT_PANEL_COLLAPSED', () => {
it('sets left panel collapsed', () => {
mutations.SET_LEFT_PANEL_COLLAPSED(localState, true);
expect(localState.leftPanelCollapsed).toBeTruthy();
mutations.SET_LEFT_PANEL_COLLAPSED(localState, false);
expect(localState.leftPanelCollapsed).toBeFalsy();
});
});
describe('SET_RIGHT_PANEL_COLLAPSED', () => {
it('sets right panel collapsed', () => {
mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true);
expect(localState.rightPanelCollapsed).toBeTruthy();
mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false);
expect(localState.rightPanelCollapsed).toBeFalsy();
});
});
describe('UPDATE_VIEWER', () => {
it('sets viewer state', () => {
mutations.UPDATE_VIEWER(localState, 'diff');
expect(localState.viewer).toBe('diff');
});
});
});
import * as utils from 'ee/ide/stores/utils';
describe('Multi-file store utils', () => {
describe('setPageTitle', () => {
it('sets the document page title', () => {
utils.setPageTitle('test');
expect(document.title).toBe('test');
});
});
describe('findIndexOfFile', () => {
let localState;
beforeEach(() => {
localState = [{
path: '1',
}, {
path: '2',
}];
});
it('finds in the index of an entry by path', () => {
const index = utils.findIndexOfFile(localState, {
path: '2',
});
expect(index).toBe(1);
});
});
describe('findEntry', () => {
let localState;
beforeEach(() => {
localState = {
tree: [{
type: 'tree',
name: 'test',
}, {
type: 'blob',
name: 'file',
}],
};
});
it('returns an entry found by name', () => {
const foundEntry = utils.findEntry(localState.tree, 'tree', 'test');
expect(foundEntry.type).toBe('tree');
expect(foundEntry.name).toBe('test');
});
it('returns undefined when no entry found', () => {
const foundEntry = utils.findEntry(localState.tree, 'blob', 'test');
expect(foundEntry).toBeUndefined();
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment