提交 213e91d4 编写于 作者: T Tim Zallmann 提交者: Phil Hughes

Resolve "Decouple multi-file editor from file list"

上级 889c7081
import $ from 'jquery';
import axios from './lib/utils/axios_utils';
const Api = {
groupsPath: '/api/:version/groups.json',
......@@ -6,6 +7,7 @@ const Api = {
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json',
projectPath: '/api/:version/projects/:id',
projectLabelsPath: '/:namespace_path/:project_path/labels',
groupLabelsPath: '/groups/:namespace_path/labels',
licensePath: '/api/:version/templates/licenses/:key',
......@@ -76,6 +78,14 @@ const Api = {
.done(projects => callback(projects));
},
// Return single project
project(projectPath) {
const url = Api.buildUrl(Api.projectPath)
.replace(':id', encodeURIComponent(projectPath));
return axios.get(url);
},
newLabel(namespacePath, projectPath, data, callback) {
let url;
......@@ -115,7 +125,7 @@ const Api = {
commitMultiple(id, data) {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
const url = Api.buildUrl(Api.commitPath)
.replace(':id', id);
.replace(':id', encodeURIComponent(id));
return this.wrapAjaxCall({
url,
type: 'POST',
......@@ -127,7 +137,7 @@ const Api = {
branchSingle(id, branch) {
const url = Api.buildUrl(Api.branchSinglePath)
.replace(':id', id)
.replace(':id', encodeURIComponent(id))
.replace(':branch', branch);
return this.wrapAjaxCall({
......
......@@ -73,7 +73,6 @@ import initLegacyFilters from './init_legacy_filters';
import initIssuableSidebar from './init_issuable_sidebar';
import initProjectVisibilitySelector from './project_visibility';
import GpgBadges from './gpg_badges';
import UserFeatureHelper from './helpers/user_feature_helper';
import initChangesDropdown from './init_changes_dropdown';
import NewGroupChild from './groups/new_group_child';
import AbuseReports from './abuse_reports';
......@@ -447,9 +446,6 @@ import Activities from './activities';
break;
case 'projects:tree:show':
shortcut_handler = new ShortcutsNavigation();
if (UserFeatureHelper.isNewRepoEnabled()) break;
new TreeView();
new BlobViewer();
new NewCommitForm($('.js-create-dir-form'));
......@@ -468,7 +464,6 @@ import Activities from './activities';
shortcut_handler = true;
break;
case 'projects:blob:show':
if (UserFeatureHelper.isNewRepoEnabled()) break;
new BlobViewer();
initBlob();
break;
......
......@@ -161,13 +161,16 @@ export default () => {
const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')];
sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (currentOpenMenu) hideMenu(currentOpenMenu);
}, getHideSubItemsInterval());
});
const topItems = sidebar.querySelector('.sidebar-top-level-items');
if (topItems) {
sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (currentOpenMenu) hideMenu(currentOpenMenu);
}, getHideSubItemsInterval());
});
}
headerHeight = document.querySelector('.nav-sidebar').offsetTop;
......
import Cookies from 'js-cookie';
export default {
isNewRepoEnabled() {
return Cookies.get('new_repo') === 'true';
},
};
<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';
......@@ -18,72 +19,48 @@
type: Array,
required: true,
},
collapsed: {
type: Boolean,
required: true,
},
},
computed: {
...mapState([
'currentProjectId',
'currentBranchId',
'rightPanelCollapsed',
]),
},
methods: {
toggleCollapsed() {
this.$emit('toggleCollapsed');
},
},
};
</script>
<template>
<div class="multi-file-commit-panel-section">
<header
class="multi-file-commit-panel-header"
:class="{
'is-collapsed': collapsed,
}"
>
<icon
name="list-bulleted"
:size="18"
css-classes="append-right-default"
/>
<template v-if="!collapsed">
{{ title }}
<button
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
@click="toggleCollapsed"
>
<i
aria-hidden="true"
class="fa fa-angle-double-right"
>
</i>
</button>
</template>
</header>
<div class="multi-file-commit-list">
<list-collapsed
v-if="collapsed"
/>
<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>
<div
v-else
class="help-block prepend-top-0"
<div class="multi-file-commit-list">
<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"
>
No changes
</div>
</template>
</div>
<list-item
:file="file"
/>
</li>
</ul>
<div
v-else
class="help-block prepend-top-0"
>
No changes
</div>
</template>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import RepoSidebar from './repo_sidebar.vue';
import RepoCommitSection from './repo_commit_section.vue';
import RepoTabs from './repo_tabs.vue';
import RepoFileButtons from './repo_file_buttons.vue';
import RepoPreview from './repo_preview.vue';
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 repoPreview from './repo_preview.vue';
import repoEditor from './repo_editor.vue';
export default {
computed: {
...mapState([
'currentBlobView',
'selectedFile',
]),
...mapGetters([
'isCollapsed',
'changedFiles',
'activeFile',
]),
},
components: {
RepoSidebar,
RepoTabs,
RepoFileButtons,
ideSidebar,
ideContextbar,
repoTabs,
repoFileButtons,
ideStatusBar,
repoEditor,
RepoCommitSection,
RepoPreview,
repoPreview,
},
mounted() {
const returnValue = 'Are you sure you want to lose unsaved changes?';
......@@ -40,24 +43,31 @@ export default {
</script>
<template>
<div
class="multi-file"
:class="{
'is-collapsed': isCollapsed
}"
<div
class="ide-view"
>
<repo-sidebar/>
<ide-sidebar/>
<div
v-if="isCollapsed"
class="multi-file-edit-pane"
>
<repo-tabs />
<component
class="multi-file-edit-pane-content"
:is="currentBlobView"
/>
<repo-file-buttons />
<template
v-if="activeFile">
<repo-tabs/>
<component
class="multi-file-edit-pane-content"
:is="currentBlobView"
/>
<repo-file-buttons/>
<ide-status-bar
:file="selectedFile"/>
</template>
<template
v-else>
<div class="ide-empty-state">
<h2 class="clgray">Welcome to the GitLab IDE</h2>
</div>
</template>
</div>
<repo-commit-section />
<ide-contextbar/>
</div>
</template>
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
import repoCommitSection from './repo_commit_section.vue';
import icon from '../../vue_shared/components/icon.vue';
export default {
components: {
repoCommitSection,
icon,
},
computed: {
...mapState([
'rightPanelCollapsed',
]),
...mapGetters([
'changedFiles',
]),
currentIcon() {
return this.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
},
},
methods: {
...mapActions([
'setPanelCollapsedStatus',
]),
toggleCollapsed() {
this.setPanelCollapsedStatus({
side: 'right',
collapsed: !this.rightPanelCollapsed,
});
},
},
};
</script>
<template>
<div
class="multi-file-commit-panel"
:class="{
'is-collapsed': rightPanelCollapsed,
}"
>
<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">
<icon
name="list-bulleted"
:size="18"
/>
Staged
</div>
<button
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
@click="toggleCollapsed"
>
<icon
:name="currentIcon"
:size="18"
/>
</button>
</header>
<repo-commit-section
class=""/>
</div>
</div>
</template>
<script>
import repoTree from './ide_repo_tree.vue';
import icon from '../../vue_shared/components/icon.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">
<icon
name="branch"
:size="12">
</icon>
{{ branch.name }}
</div>
<div class="branch-header-btns">
<new-dropdown
:project-id="projectId"
:branch="branch.name"
path=""/>
</div>
</div>
<div>
<repo-tree
:treeId="branch.treeId"/>
</div>
</div>
</template>
<script>
import branchesTree from './ide_project_branches_tree.vue';
import projectAvatarImage from '../../vue_shared/components/project_avatar/image.vue';
export default {
components: {
branchesTree,
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>
<div class="multi-file-commit-panel-inner-scroll">
<branches-tree
v-for="(branch, index) in project.branches"
:key="branch.name"
:project-id="project.path_with_namespace"
:branch="branch"/>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { mapState } from 'vuex';
import RepoPreviousDirectory from './repo_prev_directory.vue';
import RepoFile from './repo_file.vue';
import RepoLoadingFile from './repo_loading_file.vue';
import { treeList } from '../stores/utils';
export default {
components: {
......@@ -10,14 +11,11 @@ export default {
'repo-file': RepoFile,
'repo-loading-file': RepoLoadingFile,
},
created() {
window.addEventListener('popstate', this.popHistoryState);
},
destroyed() {
window.removeEventListener('popstate', this.popHistoryState);
},
mounted() {
this.getTreeData();
props: {
treeId: {
type: String,
required: true,
},
},
computed: {
...mapState([
......@@ -29,57 +27,40 @@ export default {
return state.project.name;
},
}),
...mapGetters([
'treeList',
'isCollapsed',
]),
},
methods: {
...mapActions([
'getTreeData',
'popHistoryState',
]),
fetchedList() {
return treeList(this.$store.state, this.treeId);
},
hasPreviousDirectory() {
return !this.isRoot && this.fetchedList.length;
},
showLoading() {
return this.loading;
},
},
};
</script>
<template>
<div class="ide-file-list">
<table class="table">
<thead>
<tr>
<th
v-if="isCollapsed"
>
</th>
<template v-else>
<th class="name multi-file-table-name">
Name
</th>
<th class="hidden-sm hidden-xs last-commit">
Last commit
</th>
<th class="hidden-xs last-update text-right">
Last update
</th>
</template>
</tr>
</thead>
<tbody>
<repo-previous-directory
v-if="!isRoot && treeList.length"
/>
<repo-loading-file
v-if="!treeList.length && loading"
v-for="n in 5"
:key="n"
/>
<repo-file
v-for="file in treeList"
:key="file.key"
:file="file"
/>
</tbody>
</table>
<div>
<div class="ide-file-list">
<table class="table">
<tbody
v-if="treeId">
<repo-previous-directory
v-if="hasPreviousDirectory"
/>
<repo-loading-file
v-if="showLoading"
v-for="n in 5"
:key="n"
/>
<repo-file
v-for="file in fetchedList"
:key="file.key"
:file="file"
/>
</tbody>
</table>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import projectTree from './ide_project_tree.vue';
import icon from '../../vue_shared/components/icon.vue';
export default {
components: {
projectTree,
icon,
},
computed: {
...mapState([
'projects',
'leftPanelCollapsed',
]),
currentIcon() {
return this.leftPanelCollapsed ? 'angle-double-right' : 'angle-double-left';
},
},
methods: {
...mapActions([
'setPanelCollapsedStatus',
]),
toggleCollapsed() {
this.setPanelCollapsedStatus({
side: 'left',
collapsed: !this.leftPanelCollapsed,
});
},
},
};
</script>
<template>
<div
class="multi-file-commit-panel"
:class="{
'is-collapsed': leftPanelCollapsed,
}"
>
<div class="multi-file-commit-panel-inner">
<project-tree
v-for="(project, index) in projects"
:key="project.id"
:project="project"/>
</div>
<button
type="button"
class="btn btn-transparent left-collapse-btn"
@click="toggleCollapsed"
>
<icon
:name="currentIcon"
:size="18"
/>
<span
v-if="!leftPanelCollapsed"
class="collapse-text"
>Collapse sidebar</span>
</button>
</div>
</template>
<script>
import { mapState } from 'vuex';
import icon from '../../vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import timeAgoMixin from '../../vue_shared/mixins/timeago';
export default {
props: {
file: {
type: Object,
required: true,
},
},
components: {
icon,
},
directives: {
tooltip,
},
mixins: [
timeAgoMixin,
],
computed: {
...mapState([
'selectedFile',
]),
},
};
</script>
<template>
<div
class="ide-status-bar">
<div>
<icon
name="branch"
:size="12">
</icon>
{{ selectedFile.branchId }}
</div>
<div>
<div
v-if="selectedFile.lastCommit && selectedFile.lastCommit.id">
Last commit:
<a
v-tooltip
:title="selectedFile.lastCommit.message"
:href="selectedFile.lastCommit.url">
{{ timeFormated(selectedFile.lastCommit.updatedAt) }} by
{{ selectedFile.lastCommit.author }}
</a>
</div>
</div>
<div
class="text-right">
{{ selectedFile.name }}
</div>
<div
class="text-right">
{{ selectedFile.eol }}
</div>
<div
class="text-right">
{{ file.editorRow }}:{{ file.editorColumn }}
</div>
<div
class="text-right">
{{ selectedFile.fileLanguage }}
</div>
</div>
</template>
......@@ -44,7 +44,7 @@
this.branchName = '';
if (this.dropdownText) {
this.dropdownText.textContent = this.currentBranch;
this.dropdownText.textContent = this.currentBranchId;
}
this.toggleDropdown();
......
<script>
import { mapState } from 'vuex';
import newModal from './modal.vue';
import upload from './upload.vue';
import icon from '../../../vue_shared/components/icon.vue';
export default {
props: {
branch: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
parent: {
type: Object,
default: null,
},
},
components: {
icon,
newModal,
......@@ -16,11 +29,6 @@
modalType: '',
};
},
computed: {
...mapState([
'path',
]),
},
methods: {
createNewItem(type) {
this.modalType = type;
......@@ -34,55 +42,59 @@
</script>
<template>
<div>
<ul class="breadcrumb repo-breadcrumb">
<li class="dropdown">
<button
type="button"
class="btn btn-default dropdown-toggle add-to-tree"
data-toggle="dropdown"
aria-label="Create new file or directory"
>
<icon
name="plus"
css-classes="pull-left"
/>
<icon
name="arrow-down"
css-classes="pull-left"
<div class="repo-new-btn pull-right">
<div class="dropdown">
<button
type="button"
class="btn btn-sm btn-default dropdown-toggle add-to-tree"
data-toggle="dropdown"
aria-label="Create new file or directory"
>
<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.prevent="createNewItem('blob')"
>
{{ __('New file') }}
</a>
</li>
<li>
<upload
:branch-id="branch"
:path="path"
:parent="parent"
/>
</button>
<ul class="dropdown-menu">
<li>
<a
href="#"
role="button"
@click.prevent="createNewItem('blob')"
>
{{ __('New file') }}
</a>
</li>
<li>
<upload
:path="path"
/>
</li>
<li>
<a
href="#"
role="button"
@click.prevent="createNewItem('tree')"
>
{{ __('New directory') }}
</a>
</li>
</ul>
</li>
</ul>
</li>
<li>
<a
href="#"
role="button"
@click.prevent="createNewItem('tree')"
>
{{ __('New directory') }}
</a>
</li>
</ul>
</div>
<new-modal
v-if="openModal"
:type="modalType"
:branch-id="branch"
:path="path"
:parent="parent"
@toggle="toggleModalOpen"
/>
</div>
......
<script>
import { mapActions } from 'vuex';
import { mapActions, mapState } from 'vuex';
import { __ } from '../../../locale';
import modal from '../../../vue_shared/components/modal.vue';
export default {
props: {
branchId: {
type: String,
required: true,
},
parent: {
type: Object,
default: null,
},
type: {
type: String,
required: true,
......@@ -28,6 +36,9 @@
]),
createEntryInStore() {
this.createTempEntry({
projectId: this.currentProjectId,
branchId: this.branchId,
parent: this.parent,
name: this.entryName.replace(new RegExp(`^${this.path}/`), ''),
type: this.type,
});
......@@ -39,6 +50,9 @@
},
},
computed: {
...mapState([
'currentProjectId',
]),
modalTitle() {
if (this.type === 'tree') {
return __('Create new directory');
......
<script>
import { mapActions } from 'vuex';
import { mapActions, mapState } from 'vuex';
export default {
props: {
path: {
branchId: {
type: String,
required: true,
},
parent: {
type: Object,
default: null,
},
},
computed: {
...mapState([
'trees',
'currentProjectId',
]),
},
methods: {
...mapActions([
......@@ -22,6 +32,9 @@
this.createTempEntry({
name,
projectId: this.currentProjectId,
branchId: this.branchId,
parent: this.parent,
type: 'blob',
content: result,
base64: !isText,
......@@ -42,6 +55,9 @@
openFile() {
Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file));
},
startFileUpload() {
this.$refs.fileUpload.click();
},
},
mounted() {
this.$refs.fileUpload.addEventListener('change', this.openFile);
......@@ -53,16 +69,19 @@
</script>
<template>
<label
role="button"
class="menu-item"
>
{{ __('Upload file') }}
<div>
<a
href="#"
role="button"
@click.prevent="startFileUpload"
>
{{ __('Upload file') }}
</a>
<input
id="file-upload"
type="file"
class="hidden"
ref="fileUpload"
/>
</label>
</div>
</template>
......@@ -20,12 +20,13 @@ export default {
submitCommitsLoading: false,
startNewMR: false,
commitMessage: '',
collapsed: true,
};
},
computed: {
...mapState([
'currentBranch',
'currentProjectId',
'currentBranchId',
'rightPanelCollapsed',
]),
...mapGetters([
'changedFiles',
......@@ -42,12 +43,13 @@ export default {
'checkCommitStatus',
'commitChanges',
'getTreeData',
'setPanelCollapsedStatus',
]),
makeCommit(newBranch = false) {
const createNewBranch = newBranch || this.startNewMR;
const payload = {
branch: createNewBranch ? `${this.currentBranch}-${new Date().getTime().toString()}` : this.currentBranch,
branch: createNewBranch ? `${this.currentBranchId}-${new Date().getTime().toString()}` : this.currentBranchId,
commit_message: this.commitMessage,
actions: this.changedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update',
......@@ -55,7 +57,7 @@ export default {
content: f.content,
encoding: f.base64 ? 'base64' : 'text',
})),
start_branch: createNewBranch ? this.currentBranch : undefined,
start_branch: createNewBranch ? this.currentBranchId : undefined,
};
this.showNewBranchModal = false;
......@@ -64,7 +66,12 @@ export default {
this.commitChanges({ payload, newMr: this.startNewMR })
.then(() => {
this.submitCommitsLoading = false;
this.getTreeData();
this.$store.dispatch('getTreeData', {
projectId: this.currentProjectId,
branch: this.currentBranchId,
endpoint: `/tree/${this.currentBranchId}`,
force: true,
});
})
.catch(() => {
this.submitCommitsLoading = false;
......@@ -86,19 +93,17 @@ export default {
});
},
toggleCollapsed() {
this.collapsed = !this.collapsed;
this.setPanelCollapsedStatus({
side: 'right',
collapsed: !this.rightPanelCollapsed,
});
},
},
};
</script>
<template>
<div
class="multi-file-commit-panel"
:class="{
'is-collapsed': collapsed,
}"
>
<div class="multi-file-commit-panel-section">
<modal
v-if="showNewBranchModal"
:primary-button-label="__('Create new branch')"
......@@ -108,28 +113,16 @@ export default {
@toggle="showNewBranchModal = false"
@submit="makeCommit(true)"
/>
<button
v-if="collapsed"
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn is-collapsed prepend-top-10 append-bottom-10"
@click="toggleCollapsed"
>
<i
aria-hidden="true"
class="fa fa-angle-double-left"
>
</i>
</button>
<commit-files-list
title="Staged"
:file-list="changedFiles"
:collapsed="collapsed"
:collapsed="rightPanelCollapsed"
@toggleCollapsed="toggleCollapsed"
/>
<form
class="form-horizontal multi-file-commit-form"
@submit.prevent="tryCommit"
v-if="!collapsed"
v-if="!rightPanelCollapsed"
>
<div class="multi-file-commit-fieldset">
<textarea
......
<script>
/* global monaco */
import { mapGetters, mapActions } from 'vuex';
import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '../../flash';
import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor';
......@@ -24,6 +24,9 @@ export default {
...mapActions([
'getRawFileData',
'changeFileContent',
'setFileLanguage',
'setEditorPosition',
'setFileEOL',
]),
initMonaco() {
if (this.shouldHideEditor) return;
......@@ -43,12 +46,36 @@ export default {
const model = this.editor.createModel(this.activeFile);
this.editor.attachModel(model);
model.onChange((m) => {
this.changeFileContent({
file: this.activeFile,
content: m.getValue(),
});
});
// Handle Cursor Position
this.editor.onPositionChange((instance, e) => {
this.setEditorPosition({
editorRow: e.position.lineNumber,
editorColumn: e.position.column,
});
});
this.editor.setPosition({
lineNumber: this.activeFile.editorRow,
column: this.activeFile.editorColumn,
});
// Handle File Language
this.setFileLanguage({
fileLanguage: model.language,
});
// Get File eol
this.setFileEOL({
eol: model.eol,
});
},
},
watch: {
......@@ -57,12 +84,22 @@ export default {
this.initMonaco();
}
},
leftPanelCollapsed() {
this.editor.updateDimensions();
},
rightPanelCollapsed() {
this.editor.updateDimensions();
},
},
computed: {
...mapGetters([
'activeFile',
'activeFileExtension',
]),
...mapState([
'leftPanelCollapsed',
'rightPanelCollapsed',
]),
shouldHideEditor() {
return this.activeFile.binary && !this.activeFile.raw;
},
......@@ -76,13 +113,14 @@ export default {
class="blob-viewer-container blob-editor-container"
>
<div
v-show="shouldHideEditor"
v-if="shouldHideEditor"
v-html="activeFile.html"
>
</div>
<div
v-show="!shouldHideEditor"
ref="editor"
class="multi-file-editor-holder"
>
</div>
</div>
......
<script>
import { mapActions, mapGetters } from 'vuex';
import { mapState } from 'vuex';
import timeAgoMixin from '../../vue_shared/mixins/timeago';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
import newDropdown from './new_dropdown/index.vue';
export default {
mixins: [
......@@ -9,20 +10,22 @@
],
components: {
skeletonLoadingContainer,
newDropdown,
},
props: {
file: {
type: Object,
required: true,
},
showExtraColumns: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters([
'isCollapsed',
...mapState([
'leftPanelCollapsed',
]),
isSubmodule() {
return this.file.type === 'submodule';
},
fileIcon() {
return {
'fa-spinner fa-spin': this.file.loading,
......@@ -30,6 +33,12 @@
'fa-folder-open': !this.file.loading && this.file.opened,
};
},
isSubmodule() {
return this.file.type === 'submodule';
},
isTree() {
return this.file.type === 'tree';
},
levelIndentation() {
return {
marginLeft: `${this.file.level * 16}px`,
......@@ -39,13 +48,39 @@
return this.file.id.substr(0, 8);
},
submoduleColSpan() {
return !this.isCollapsed && this.isSubmodule ? 3 : 1;
return !this.leftPanelCollapsed && this.isSubmodule ? 3 : 1;
},
fileClass() {
if (this.file.type === 'blob') {
if (this.file.active) {
return 'file-open file-active';
}
return this.file.opened ? 'file-open' : '';
}
return '';
},
changedClass() {
return {
'fa-circle unsaved-icon': this.file.changed || this.file.tempFile,
};
},
},
methods: {
...mapActions([
'clickedTreeRow',
]),
clickFile(row) {
// Manual Action if a tree is selected/opened
if (this.file.type === 'tree' && this.$router.currentRoute.path === `/project${row.url}`) {
this.$store.dispatch('toggleTreeOpen', {
endpoint: this.file.url,
tree: this.file,
});
}
this.$router.push(`/project${row.url}`);
},
},
updated() {
if (this.file.type === 'blob' && this.file.active) {
this.$el.scrollIntoView();
}
},
};
</script>
......@@ -53,7 +88,8 @@
<template>
<tr
class="file"
@click.prevent="clickedTreeRow(file)">
:class="fileClass"
@click="clickFile(file)">
<td
class="multi-file-table-name"
:colspan="submoduleColSpan"
......@@ -66,11 +102,23 @@
>
</i>
<a
:href="file.url"
class="repo-file-name"
>
{{ file.name }}
</a>
<new-dropdown
v-if="isTree"
:project-id="file.projectId"
:branch="file.branchId"
:path="file.path"
:parent="file"/>
<i
class="fa"
v-if="changedClass"
:class="changedClass"
aria-hidden="true"
>
</i>
<template v-if="isSubmodule && file.id">
@
<span class="commit-sha">
......@@ -84,7 +132,7 @@
</template>
</td>
<template v-if="!isCollapsed && !isSubmodule">
<template v-if="showExtraColumns && !isSubmodule">
<td class="multi-file-table-col-commit-message hidden-sm hidden-xs">
<a
v-if="file.lastCommit.message"
......
<script>
import { mapGetters } from 'vuex';
import { mapState } from 'vuex';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
export default {
......@@ -7,8 +7,8 @@
skeletonLoadingContainer,
},
computed: {
...mapGetters([
'isCollapsed',
...mapState([
'leftPanelCollapsed',
]),
},
};
......@@ -24,7 +24,7 @@
:small="true"
/>
</td>
<template v-if="!isCollapsed">
<template v-if="!leftPanelCollapsed">
<td
class="hidden-sm hidden-xs">
<skeleton-loading-container
......
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
import { mapState, mapActions } from 'vuex';
export default {
computed: {
...mapState([
'parentTreeUrl',
]),
...mapGetters([
'isCollapsed',
'leftPanelCollapsed',
]),
colSpanCondition() {
return this.isCollapsed ? undefined : 3;
return this.leftPanelCollapsed ? undefined : 3;
},
},
methods: {
......
......@@ -27,16 +27,18 @@ export default {
methods: {
...mapActions([
'setFileActive',
'closeFile',
]),
clickFile(tab) {
this.$router.push(`/project${tab.url}`);
},
},
};
</script>
<template>
<li
@click="setFileActive(tab)"
@click="clickFile(tab)"
>
<button
type="button"
......
import Vue from 'vue';
import VueRouter from 'vue-router';
import store from './stores';
import flash from '../flash';
import {
getTreeEntry,
} from './stores/utils';
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('getTreeData', {
projectId: fullProjectId,
branch: to.params.branch,
endpoint: `/tree/${to.params.branch}`,
})
.then(() => {
if (to.params[0]) {
const treeEntry = getTreeEntry(store, `${to.params.namespace}/${to.params.project}/${to.params.branch}`, to.params[0]);
if (treeEntry) {
store.dispatch('handleTreeEntryAction', treeEntry);
}
}
})
.catch((e) => {
flash('Error while loading the branch files. Please try again.');
throw e;
});
}
})
.catch((e) => {
flash('Error while loading the project data. Please try again.');
throw e;
});
}
next();
});
export default router;
import Vue from 'vue';
import { mapActions } from 'vuex';
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
import Repo from './components/repo.vue';
import RepoEditButton from './components/repo_edit_button.vue';
import newBranchForm from './components/new_branch_form.vue';
import newDropdown from './components/new_dropdown/index.vue';
import ide from './components/ide.vue';
import store from './stores';
import router from './ide_router';
import Translate from '../vue_shared/translate';
import ContextualSidebar from '../contextual_sidebar';
function initRepo(el) {
function initIde(el) {
if (!el) return null;
return new Vue({
el,
store,
router,
components: {
repo: Repo,
ide,
},
methods: {
...mapActions([
......@@ -26,11 +27,6 @@ function initRepo(el) {
const data = el.dataset;
this.setInitialData({
project: {
id: data.projectId,
name: data.projectName,
url: data.projectUrl,
},
endpoints: {
rootEndpoint: data.url,
newMergeRequestUrl: data.newMergeRequestUrl,
......@@ -38,69 +34,22 @@ function initRepo(el) {
},
canCommit: convertPermissionToBoolean(data.canCommit),
onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch),
currentRef: data.ref,
path: data.currentPath,
currentBranch: data.currentBranch,
isRoot: convertPermissionToBoolean(data.root),
isInitialRoot: convertPermissionToBoolean(data.root),
});
},
render(createElement) {
return createElement('repo');
},
});
}
function initRepoEditButton(el) {
return new Vue({
el,
store,
components: {
repoEditButton: RepoEditButton,
},
render(createElement) {
return createElement('repo-edit-button');
},
});
}
function initNewDropdown(el) {
return new Vue({
el,
store,
components: {
newDropdown,
},
render(createElement) {
return createElement('new-dropdown');
},
});
}
function initNewBranchForm() {
const el = document.querySelector('.js-new-branch-dropdown');
if (!el) return null;
return new Vue({
el,
components: {
newBranchForm,
},
store,
render(createElement) {
return createElement('new-branch-form');
return createElement('ide');
},
});
}
const repo = document.getElementById('repo');
const editButton = document.querySelector('.editable-mode');
const newDropdownHolder = document.querySelector('.js-new-dropdown');
const ideElement = document.getElementById('ide');
Vue.use(Translate);
initRepo(repo);
initRepoEditButton(editButton);
initNewBranchForm();
initNewDropdown(newDropdownHolder);
initIde(ideElement);
const contextualSidebar = new ContextualSidebar();
contextualSidebar.bindEvents();
......@@ -28,6 +28,14 @@ export default class Model {
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;
}
......
......@@ -22,6 +22,11 @@ export default class Editor {
this.modelManager = new ModelManager(this.monaco),
this.decorationsController = new DecorationsController(this),
);
this.debouncedUpdate = _.debounce(() => {
this.updateDimensions();
}, 200);
window.addEventListener('resize', this.debouncedUpdate, false);
}
createInstance(domElement) {
......@@ -32,6 +37,9 @@ export default class Editor {
readOnly: false,
contextmenu: true,
scrollBeyondLastLine: false,
minimap: {
enabled: false,
},
}),
this.dirtyDiffController = new DirtyDiffController(
this.modelManager, this.decorationsController,
......@@ -70,10 +78,32 @@ export default class Editor {
dispose() {
this.disposable.dispose();
window.removeEventListener('resize', this.debouncedUpdate);
// dispose main monaco instance
if (this.instance) {
this.instance = null;
}
}
updateDimensions() {
this.instance.layout();
}
setPosition({ lineNumber, column }) {
this.instance.revealPositionInCenter({
lineNumber,
column,
});
this.instance.setPosition({
lineNumber,
column,
});
}
onPositionChange(cb) {
this.disposable.add(
this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)),
);
}
}
......@@ -23,8 +23,11 @@ export default {
return Vue.http.get(file.rawPath, { params: { format: 'json' } })
.then(res => res.text());
},
getBranchData(projectId, currentBranch) {
return Api.branchSingle(projectId, currentBranch);
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);
......
......@@ -6,9 +6,11 @@ import * as types from './mutation_types';
export const redirectToUrl = (_, url) => visitUrl(url);
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const setInitialData = ({ commit }, data) =>
commit(types.SET_INITIAL_DATA, data);
export const closeDiscardPopup = ({ commit }) => commit(types.TOGGLE_DISCARD_POPUP, false);
export const closeDiscardPopup = ({ commit }) =>
commit(types.TOGGLE_DISCARD_POPUP, false);
export const discardAllChanges = ({ commit, getters, dispatch }) => {
const changedFiles = getters.changedFiles;
......@@ -26,7 +28,10 @@ export const closeAllFiles = ({ state, dispatch }) => {
state.openFiles.forEach(file => dispatch('closeFile', { file }));
};
export const toggleEditMode = ({ state, commit, getters, dispatch }, force = false) => {
export const toggleEditMode = (
{ state, commit, getters, dispatch },
force = false,
) => {
const changedFiles = getters.changedFiles;
if (changedFiles.length && !force) {
......@@ -50,67 +55,105 @@ export const toggleBlobView = ({ commit, state }) => {
}
};
export const checkCommitStatus = ({ state }) => service.getBranchData(
state.project.id,
state.currentBranch,
)
.then((data) => {
const { id } = data.commit;
if (state.currentRef !== id) {
return true;
}
export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
if (side === 'left') {
commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed);
} else {
commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed);
}
};
return false;
})
.catch(() => flash('Error checking branch data. Please try again.'));
export const commitChanges = ({ commit, state, dispatch, getters }, { payload, newMr }) =>
service.commit(state.project.id, payload)
.then((data) => {
const { branch } = payload;
if (!data.short_id) {
flash(data.message);
return;
}
export const checkCommitStatus = ({ state }) =>
service
.getBranchData(state.currentProjectId, state.currentBranchId)
.then((data) => {
const { id } = data.commit;
const selectedBranch =
state.projects[state.currentProjectId].branches[state.currentBranchId];
if (selectedBranch.workingReference !== id) {
return true;
}
return false;
})
.catch(() => flash('Error checking branch data. Please try again.'));
export const commitChanges = (
{ commit, state, dispatch, getters },
{ payload, newMr },
) =>
service
.commit(state.currentProjectId, payload)
.then((data) => {
const { branch } = payload;
if (!data.short_id) {
flash(data.message);
return;
}
const selectedProject = state.projects[state.currentProjectId];
const lastCommit = {
commit_path: `${selectedProject.web_url}/commit/${data.id}`,
commit: {
message: data.message,
authored_date: data.committed_date,
},
};
flash(
`Your changes have been committed. Commit ${data.short_id} with ${
data.stats.additions
} additions, ${data.stats.deletions} deletions.`,
'notice',
);
if (newMr) {
dispatch(
'redirectToUrl',
`${
selectedProject.web_url
}/merge_requests/new?merge_request%5Bsource_branch%5D=${branch}`,
);
} else {
commit(types.SET_BRANCH_WORKING_REFERENCE, {
projectId: state.currentProjectId,
branchId: state.currentBranchId,
reference: data.id,
});
const lastCommit = {
commit_path: `${state.project.url}/commit/${data.id}`,
commit: {
message: data.message,
authored_date: data.committed_date,
},
};
flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
if (newMr) {
dispatch('redirectToUrl', `${state.endpoints.newMergeRequestUrl}${branch}`);
} else {
commit(types.SET_COMMIT_REF, data.id);
getters.changedFiles.forEach((entry) => {
commit(types.SET_LAST_COMMIT_DATA, {
entry,
lastCommit,
getters.changedFiles.forEach((entry) => {
commit(types.SET_LAST_COMMIT_DATA, {
entry,
lastCommit,
});
});
});
dispatch('discardAllChanges');
dispatch('closeAllFiles');
dispatch('toggleEditMode');
dispatch('discardAllChanges');
dispatch('closeAllFiles');
window.scrollTo(0, 0);
}
})
.catch(() => flash('Error committing changes. Please try again.'));
window.scrollTo(0, 0);
}
})
.catch(() => flash('Error committing changes. Please try again.'));
export const createTempEntry = ({ state, dispatch }, { name, type, content = '', base64 = false }) => {
export const createTempEntry = (
{ state, dispatch },
{ projectId, branchId, parent, name, type, content = '', base64 = false },
) => {
const selectedParent = parent || state.trees[`${projectId}/${branchId}`];
if (type === 'tree') {
dispatch('createTempTree', name);
dispatch('createTempTree', {
projectId,
branchId,
parent: selectedParent,
name,
});
} else if (type === 'blob') {
dispatch('createTempFile', {
tree: state,
projectId,
branchId,
parent: selectedParent,
name,
base64,
content,
......@@ -118,17 +161,6 @@ export const createTempEntry = ({ state, dispatch }, { name, type, content = '',
}
};
export const popHistoryState = ({ state, dispatch, getters }) => {
const treeList = getters.treeList;
const tree = treeList.find(file => file.url === state.previousUrl);
if (!tree) return;
if (tree.type === 'tree') {
dispatch('toggleTreeOpen', { endpoint: tree.url, tree });
}
};
export const scrollToTab = () => {
Vue.nextTick(() => {
const tabs = document.getElementById('tabs');
......@@ -143,4 +175,5 @@ export const scrollToTab = () => {
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
export * from './actions/branch';
import service from '../../services';
import flash from '../../../flash';
import * as types from '../mutation_types';
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.');
reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
});
} else {
resolve(state.projects[`${projectId}`].branches[branchId]);
}
});
export const createNewBranch = ({ state, commit }, branch) => service.createBranch(
state.currentProjectId,
{
branch,
ref: state.currentBranchId,
},
)
.then(res => res.json())
.then((data) => {
const branchName = data.name;
const url = location.href.replace(state.currentBranchId, branchName);
if (this.$router) this.$router.push(url);
commit(types.SET_CURRENT_BRANCH, branchName);
});
......@@ -2,9 +2,9 @@ import { normalizeHeaders } from '../../../lib/utils/common_utils';
import flash from '../../../flash';
import service from '../../services';
import * as types from '../mutation_types';
import router from '../../ide_router';
import {
findEntry,
pushState,
setPageTitle,
createTemp,
findIndexOfFile,
......@@ -25,7 +25,7 @@ export const closeFile = ({ commit, state, dispatch }, { file, force = false })
dispatch('setFileActive', nextFileToOpen);
} else if (!state.openFiles.length) {
pushState(file.parentTreeUrl);
router.push(`/project/${file.projectId}/tree/${file.branchId}/`);
}
dispatch('getLastCommitData');
......@@ -45,6 +45,9 @@ export const setFileActive = ({ commit, state, getters, dispatch }, file) => {
// reset hash for line highlighting
location.hash = '';
commit(types.SET_CURRENT_PROJECT, file.projectId);
commit(types.SET_CURRENT_BRANCH, file.branchId);
};
export const getFileData = ({ state, commit, dispatch }, file) => {
......@@ -63,8 +66,6 @@ export const getFileData = ({ state, commit, dispatch }, file) => {
commit(types.TOGGLE_FILE_OPEN, file);
dispatch('setFileActive', file);
commit(types.TOGGLE_LOADING, file);
pushState(file.url);
})
.catch(() => {
commit(types.TOGGLE_LOADING, file);
......@@ -82,21 +83,39 @@ export const changeFileContent = ({ commit }, { file, content }) => {
commit(types.UPDATE_FILE_CONTENT, { file, content });
};
export const createTempFile = ({ state, commit, dispatch }, { tree, name, content = '', base64 = '' }) => {
export const setFileLanguage = ({ state, commit }, { fileLanguage }) => {
commit(types.SET_FILE_LANGUAGE, { file: state.selectedFile, fileLanguage });
};
export const setFileEOL = ({ state, commit }, { eol }) => {
commit(types.SET_FILE_EOL, { file: state.selectedFile, eol });
};
export const setEditorPosition = ({ state, commit }, { editorRow, editorColumn }) => {
commit(types.SET_FILE_POSITION, { file: state.selectedFile, editorRow, editorColumn });
};
export const createTempFile = ({ state, commit, dispatch }, { projectId, branchId, parent, name, content = '', base64 = '' }) => {
const path = parent.path !== undefined ? parent.path : '';
// We need to do the replacement otherwise the web_url + file.url duplicate
const newUrl = `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${name}`;
const file = createTemp({
name: name.replace(`${state.path}/`, ''),
path: tree.path,
projectId,
branchId,
name: name.replace(`${path}/`, ''),
path,
type: 'blob',
level: tree.level !== undefined ? tree.level + 1 : 0,
level: parent.level !== undefined ? parent.level + 1 : 0,
changed: true,
content,
base64,
url: newUrl,
});
if (findEntry(tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`);
if (findEntry(parent.tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`);
commit(types.CREATE_TMP_FILE, {
parent: tree,
parent,
file,
});
commit(types.TOGGLE_FILE_OPEN, file);
......@@ -106,5 +125,7 @@ export const createTempFile = ({ state, commit, dispatch }, { tree, name, conten
dispatch('toggleEditMode', true);
}
router.push(`/project${file.url}`);
return Promise.resolve(file);
};
import service from '../../services';
import flash from '../../../flash';
import * as types from '../mutation_types';
// eslint-disable-next-line import/prefer-default-export
export const getProjectData = (
{ commit, state, dispatch },
{ namespace, projectId, force = false } = {},
) => new Promise((resolve, reject) => {
if (!state.projects[`${namespace}/${projectId}`] || force) {
service.getProjectData(namespace, projectId)
.then(res => res.data)
.then((data) => {
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.');
reject(new Error(`Project not loaded ${namespace}/${projectId}`));
});
} else {
resolve(state.projects[`${namespace}/${projectId}`]);
}
});
......@@ -3,8 +3,8 @@ import { normalizeHeaders } from '../../../lib/utils/common_utils';
import flash from '../../../flash';
import service from '../../services';
import * as types from '../mutation_types';
import router from '../../ide_router';
import {
pushState,
setPageTitle,
findEntry,
createTemp,
......@@ -13,59 +13,69 @@ import {
export const getTreeData = (
{ commit, state, dispatch },
{ endpoint = state.endpoints.rootEndpoint, tree = state } = {},
) => {
commit(types.TOGGLE_LOADING, tree);
service.getTreeData(endpoint)
.then((res) => {
const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
setPageTitle(pageTitle);
return res.json();
})
.then((data) => {
const prevLastCommitPath = tree.lastCommitPath;
if (!state.isInitialRoot) {
commit(types.SET_ROOT, data.path === '/');
}
{ endpoint, tree = null, projectId, branch, force = false } = {},
) => new Promise((resolve, reject) => {
// We already have the base tree so we resolve immediately
if (!tree && state.trees[`${projectId}/${branch}`] && !force) {
resolve();
} else {
if (tree) commit(types.TOGGLE_LOADING, tree);
const selectedProject = state.projects[projectId];
// We are merging the web_url that we got on the project info with the endpoint
// we got on the tree entry, as both contain the projectId, we replace it in the tree endpoint
const completeEndpoint = selectedProject.web_url + (endpoint).replace(projectId, '');
if (completeEndpoint && (!tree || !tree.tempFile)) {
service.getTreeData(completeEndpoint)
.then((res) => {
const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
setPageTitle(pageTitle);
return res.json();
})
.then((data) => {
if (!state.isInitialRoot) {
commit(types.SET_ROOT, data.path === '/');
}
dispatch('updateDirectoryData', { data, tree });
commit(types.SET_PARENT_TREE_URL, data.parent_tree_url);
commit(types.SET_LAST_COMMIT_URL, { tree, url: data.last_commit_path });
commit(types.TOGGLE_LOADING, tree);
dispatch('updateDirectoryData', { data, tree, projectId, branch });
const selectedTree = tree || state.trees[`${projectId}/${branch}`];
if (prevLastCommitPath !== null) {
dispatch('getLastCommitData', tree);
}
commit(types.SET_PARENT_TREE_URL, data.parent_tree_url);
commit(types.SET_LAST_COMMIT_URL, { tree: selectedTree, url: data.last_commit_path });
if (tree) commit(types.TOGGLE_LOADING, selectedTree);
pushState(endpoint);
})
.catch(() => {
flash('Error loading tree data. Please try again.');
commit(types.TOGGLE_LOADING, tree);
});
};
const prevLastCommitPath = selectedTree.lastCommitPath;
if (prevLastCommitPath !== null) {
dispatch('getLastCommitData', selectedTree);
}
resolve(data);
})
.catch((e) => {
flash('Error loading tree data. Please try again.');
if (tree) commit(types.TOGGLE_LOADING, tree);
reject(e);
});
} else {
resolve();
}
}
});
export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => {
if (tree.opened) {
// send empty data to clear the tree
const data = { trees: [], blobs: [], submodules: [] };
pushState(tree.parentTreeUrl);
commit(types.SET_PREVIOUS_URL, tree.parentTreeUrl);
dispatch('updateDirectoryData', { data, tree });
dispatch('updateDirectoryData', { data, tree, projectId: tree.projectId, branchId: tree.branchId });
} else {
commit(types.SET_PREVIOUS_URL, endpoint);
dispatch('getTreeData', { endpoint, tree });
dispatch('getTreeData', { endpoint, tree, projectId: tree.projectId, branch: tree.branchId });
}
commit(types.TOGGLE_TREE_OPEN, tree);
};
export const clickedTreeRow = ({ commit, dispatch }, row) => {
export const handleTreeEntryAction = ({ commit, dispatch }, row) => {
if (row.type === 'tree') {
dispatch('toggleTreeOpen', {
endpoint: row.url,
......@@ -73,7 +83,6 @@ export const clickedTreeRow = ({ commit, dispatch }, row) => {
});
} else if (row.type === 'submodule') {
commit(types.TOGGLE_LOADING, row);
visitUrl(row.url);
} else if (row.type === 'blob' && row.opened) {
dispatch('setFileActive', row);
......@@ -82,43 +91,46 @@ export const clickedTreeRow = ({ commit, dispatch }, row) => {
}
};
export const createTempTree = ({ state, commit, dispatch }, name) => {
let tree = state;
export const createTempTree = (
{ state, commit, dispatch },
{ projectId, branchId, parent, name },
) => {
let selectedTree = parent;
const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/');
dirNames.forEach((dirName) => {
const foundEntry = findEntry(tree, 'tree', dirName);
const foundEntry = findEntry(selectedTree.tree, 'tree', dirName);
if (!foundEntry) {
const path = selectedTree.path !== undefined ? selectedTree.path : '';
const tmpEntry = createTemp({
projectId,
branchId,
name: dirName,
path: tree.path,
path,
type: 'tree',
level: tree.level !== undefined ? tree.level + 1 : 0,
level: selectedTree.level !== undefined ? selectedTree.level + 1 : 0,
tree: [],
url: `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${dirName}`,
});
commit(types.CREATE_TMP_TREE, {
parent: tree,
parent: selectedTree,
tmpEntry,
});
commit(types.TOGGLE_TREE_OPEN, tmpEntry);
tree = tmpEntry;
router.push(`/project${tmpEntry.url}`);
selectedTree = tmpEntry;
} else {
tree = foundEntry;
selectedTree = foundEntry;
}
});
if (tree.tempFile) {
dispatch('createTempFile', {
tree,
name: '.gitkeep',
});
}
};
export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => {
if (tree.lastCommitPath === null || getters.isCollapsed) return;
if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return;
service.getTreeLastCommit(tree.lastCommitPath)
.then((res) => {
......@@ -130,7 +142,7 @@ export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = s
})
.then((data) => {
data.forEach((lastCommit) => {
const entry = findEntry(tree, lastCommit.type, lastCommit.file_name);
const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name);
if (entry) {
commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit });
......@@ -142,11 +154,24 @@ export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = s
.catch(() => flash('Error fetching log data.'));
};
export const updateDirectoryData = ({ commit, state }, { data, tree }) => {
const level = tree.level !== undefined ? tree.level + 1 : 0;
export const updateDirectoryData = (
{ commit, state },
{ data, tree, projectId, branch },
) => {
if (!tree) {
const existingTree = state.trees[`${projectId}/${branch}`];
if (!existingTree) {
commit(types.CREATE_TREE, { treePath: `${projectId}/${branch}` });
}
}
const selectedTree = tree || state.trees[`${projectId}/${branch}`];
const level = selectedTree.level !== undefined ? selectedTree.level + 1 : 0;
const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl;
const createEntry = (entry, type) => createOrMergeEntry({
tree,
tree: selectedTree,
projectId: `${projectId}`,
branchId: branch,
entry,
level,
type,
......@@ -159,5 +184,5 @@ export const updateDirectoryData = ({ commit, state }, { data, tree }) => {
...data.blobs.map(b => createEntry(b, 'blob')),
];
commit(types.SET_DIRECTORY_DATA, { tree, data: formattedData });
commit(types.SET_DIRECTORY_DATA, { tree: selectedTree, data: formattedData });
};
import _ from 'underscore';
/*
Takes the multi-dimensional tree and returns a flattened array.
This allows for the table to recursively render the table rows but keeps the data
structure nested to make it easier to add new files/directories.
*/
export const treeList = (state) => {
const mapTree = arr => (!arr.tree.length ? [] : _.map(arr.tree, a => [a, mapTree(a)]));
return _.chain(state.tree)
.map(arr => [arr, mapTree(arr)])
.flatten()
.value();
};
export const changedFiles = state => state.openFiles.filter(file => file.changed);
export const activeFile = state => state.openFiles.find(file => file.active);
export const activeFile = state => state.openFiles.find(file => file.active) || null;
export const activeFileExtension = (state) => {
const file = activeFile(state);
return file ? `.${file.path.split('.').pop()}` : '';
};
export const isCollapsed = state => !!state.openFiles.length;
export const canEditFile = (state) => {
const currentActiveFile = activeFile(state);
const openedFiles = state.openFiles;
return state.canCommit &&
state.onTopOfBranch &&
openedFiles.length &&
(currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary);
(currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary);
};
export const addedFiles = state => changedFiles(state).filter(f => f.tempFile);
......
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
export const SET_COMMIT_REF = 'SET_COMMIT_REF';
export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL';
export const SET_ROOT = 'SET_ROOT';
export const SET_PREVIOUS_URL = 'SET_PREVIOUS_URL';
export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
// 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 CREATE_TMP_TREE = 'CREATE_TMP_TREE';
export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL';
export const CREATE_TREE = 'CREATE_TREE';
// File mutation types
export const SET_FILE_DATA = 'SET_FILE_DATA';
......@@ -18,6 +29,9 @@ 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 CREATE_TMP_FILE = 'CREATE_TMP_FILE';
......@@ -28,3 +42,4 @@ export const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE';
export const TOGGLE_DISCARD_POPUP = 'TOGGLE_DISCARD_POPUP';
export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH';
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';
......@@ -32,29 +33,32 @@ export default {
discardPopupOpen,
});
},
[types.SET_COMMIT_REF](state, ref) {
Object.assign(state, {
currentRef: ref,
});
},
[types.SET_ROOT](state, isRoot) {
Object.assign(state, {
isRoot,
isInitialRoot: isRoot,
});
},
[types.SET_PREVIOUS_URL](state, previousUrl) {
[types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) {
Object.assign(state, {
leftPanelCollapsed: collapsed,
});
},
[types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) {
Object.assign(state, {
previousUrl,
rightPanelCollapsed: collapsed,
});
},
[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,
});
},
...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 }) {
// Add client side properties
Object.assign(branch, {
treeId: `${projectPath}/${branchName}`,
active: true,
workingReference: '',
});
Object.assign(state.projects[projectPath], {
branches: {
[branchName]: branch,
},
});
},
[types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) {
Object.assign(state.projects[projectId].branches[branchId], {
workingReference: reference,
});
},
};
......@@ -6,6 +6,10 @@ export default {
Object.assign(file, {
active,
});
Object.assign(state, {
selectedFile: file,
});
},
[types.TOGGLE_FILE_OPEN](state, file) {
Object.assign(file, {
......@@ -42,6 +46,22 @@ export default {
changed,
});
},
[types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) {
Object.assign(file, {
fileLanguage,
});
},
[types.SET_FILE_EOL](state, { file, eol }) {
Object.assign(file, {
eol,
});
},
[types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) {
Object.assign(file, {
editorRow,
editorColumn,
});
},
[types.DISCARD_FILE_CHANGES](state, file) {
Object.assign(file, {
content: '',
......
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,
}),
});
},
};
......@@ -6,6 +6,15 @@ export default {
opened: !tree.opened,
});
},
[types.CREATE_TREE](state, { treePath }) {
Object.assign(state, {
trees: Object.assign({}, state.trees, {
[treePath]: {
tree: [],
},
}),
});
},
[types.SET_DIRECTORY_DATA](state, { data, tree }) {
Object.assign(tree, {
tree: data,
......
export default () => ({
canCommit: false,
currentBranch: '',
currentBlobView: 'repo-preview',
currentRef: '',
currentProjectId: '',
currentBranchId: '',
currentBlobView: 'repo-editor',
discardPopupOpen: false,
editMode: false,
editMode: true,
endpoints: {},
isRoot: false,
isInitialRoot: false,
......@@ -12,13 +12,11 @@ export default () => ({
loading: false,
onTopOfBranch: false,
openFiles: [],
selectedFile: null,
path: '',
project: {
id: 0,
name: '',
url: '',
},
parentTreeUrl: '',
previousUrl: '',
tree: [],
trees: {},
projects: {},
leftPanelCollapsed: false,
rightPanelCollapsed: true,
});
......@@ -2,6 +2,8 @@ export const dataStructure = () => ({
id: '',
key: '',
type: '',
projectId: '',
branchId: '',
name: '',
url: '',
path: '',
......@@ -15,9 +17,11 @@ export const dataStructure = () => ({
changed: false,
lastCommitPath: '',
lastCommit: {
id: '',
url: '',
message: '',
updatedAt: '',
author: '',
},
tree_url: '',
blamePath: '',
......@@ -31,11 +35,17 @@ export const dataStructure = () => ({
parentTreeUrl: '',
renderError: false,
base64: false,
editorRow: 1,
editorColumn: 1,
fileLanguage: '',
eol: '',
});
export const decorateData = (entity) => {
const {
id,
projectId,
branchId,
type,
url,
name,
......@@ -56,6 +66,8 @@ export const decorateData = (entity) => {
return {
...dataStructure(),
id,
projectId,
branchId,
key: `${name}-${type}-${id}`,
type,
name,
......@@ -75,24 +87,51 @@ export const decorateData = (entity) => {
};
};
export const findEntry = (state, type, name) => state.tree.find(
/*
Takes the multi-dimensional tree and returns a flattened array.
This allows for the table to recursively render the table rows but keeps the data
structure nested to make it easier to add new files/directories.
*/
export const treeList = (state, treeId) => {
const baseTree = state.trees[treeId];
if (baseTree) {
const mapTree = arr => (!arr.tree || !arr.tree.length ?
[] : _.map(arr.tree, a => [a, mapTree(a)]));
return _.chain(baseTree.tree)
.map(arr => [arr, mapTree(arr)])
.flatten()
.value();
}
return [];
};
export const getTree = state => (namespace, projectId, branch) => state.trees[`${namespace}/${projectId}/${branch}`];
export const getTreeEntry = (store, treeId, path) => {
const fileList = treeList(store.state, treeId);
return fileList ? fileList.find(file => file.path === path) : null;
};
export const findEntry = (tree, type, name) => tree.find(
f => f.type === type && f.name === name,
);
export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path);
export const setPageTitle = (title) => {
document.title = title;
};
export const pushState = (url) => {
history.pushState({ url }, '', url);
};
export const createTemp = ({ name, path, type, level, changed, content, base64 }) => {
export const createTemp = ({
projectId, branchId, name, path, type, level, changed, content, base64, url,
}) => {
const treePath = path ? `${path}/${name}` : name;
return decorateData({
id: new Date().getTime().toString(),
projectId,
branchId,
name,
type,
tempFile: true,
......@@ -104,11 +143,18 @@ export const createTemp = ({ name, path, type, level, changed, content, base64 }
level,
base64,
renderError: base64,
url,
});
};
export const createOrMergeEntry = ({ tree, entry, type, parentTreeUrl, level }) => {
const found = findEntry(tree, type, entry.name);
export const createOrMergeEntry = ({ tree,
projectId,
branchId,
entry,
type,
parentTreeUrl,
level }) => {
const found = findEntry(tree.tree || tree, type, entry.name);
if (found) {
return Object.assign({}, found, {
......@@ -120,6 +166,8 @@ export const createOrMergeEntry = ({ tree, entry, type, parentTreeUrl, level })
return decorateData({
...entry,
projectId,
branchId,
type,
parentTreeUrl,
level,
......
......@@ -6,11 +6,12 @@ export default class NewCommitForm {
this.branchName = form.find('.js-branch-name');
this.originalBranch = form.find('.js-original-branch');
this.createMergeRequest = form.find('.js-create-merge-request');
this.createMergeRequestContainer = form.find('.js-create-merge-request-container');
this.createMergeRequestContainer = form.find(
'.js-create-merge-request-container',
);
this.branchName.keyup(this.renderDestination);
this.renderDestination();
}
renderDestination() {
var different;
different = this.branchName.val() !== this.originalBranch.val();
......@@ -23,6 +24,6 @@ export default class NewCommitForm {
this.createMergeRequestContainer.hide();
this.createMergeRequest.prop('checked', false);
}
return this.wasDifferent = different;
return (this.wasDifferent = different);
}
}
import service from '../../services';
import * as types from '../mutation_types';
import { pushState } from '../utils';
// eslint-disable-next-line import/prefer-default-export
export const createNewBranch = ({ state, commit }, branch) => service.createBranch(
state.project.id,
{
branch,
ref: state.currentBranch,
},
).then(res => res.json())
.then((data) => {
const branchName = data.name;
const url = location.href.replace(state.currentBranch, branchName);
pushState(url);
commit(types.SET_CURRENT_BRANCH, branchName);
});
import * as types from '../mutation_types';
export default {
[types.SET_CURRENT_BRANCH](state, currentBranch) {
Object.assign(state, {
currentBranch,
});
},
};
<script>
/* This is a re-usable vue component for rendering a project avatar that
does not need to link to the project's profile. The image and an optional
tooltip can be configured by props passed to this component.
Sample configuration:
<project-avatar-image
:lazy="true"
:img-src="projectAvatarSrc"
:img-alt="tooltipText"
:tooltip-text="tooltipText"
tooltip-placement="top"
/>
*/
import defaultAvatarUrl from 'images/no_avatar.png';
import { placeholderImage } from '../../../lazy_loader';
import tooltip from '../../directives/tooltip';
export default {
name: 'ProjectAvatarImage',
props: {
lazy: {
type: Boolean,
required: false,
default: false,
},
imgSrc: {
type: String,
required: false,
default: defaultAvatarUrl,
},
cssClasses: {
type: String,
required: false,
default: '',
},
imgAlt: {
type: String,
required: false,
default: 'project avatar',
},
size: {
type: Number,
required: false,
default: 20,
},
tooltipText: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'top',
},
},
directives: {
tooltip,
},
computed: {
// API response sends null when gravatar is disabled and
// we provide an empty string when we use it inside project avatar link.
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
},
resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource;
},
tooltipContainer() {
return this.tooltipText ? 'body' : null;
},
avatarSizeClass() {
return `s${this.size}`;
},
},
};
</script>
<template>
<img
v-tooltip
class="avatar"
:class="{
lazy,
[avatarSizeClass]: true,
[cssClasses]: true
}"
:src="resultantSrcAttribute"
:width="size"
:height="size"
:alt="imgAlt"
:data-src="sanitizedSource"
:data-container="tooltipContainer"
:data-placement="tooltipPlacement"
:title="tooltipText"
/>
</template>
......@@ -23,7 +23,6 @@
.context-header {
position: relative;
margin-right: 2px;
width: $contextual-sidebar-width;
a {
transition: padding $sidebar-transition-duration;
......
......@@ -219,6 +219,7 @@ $gl-input-padding: 10px;
$gl-vert-padding: 6px;
$gl-padding-top: 10px;
$gl-sidebar-padding: 22px;
$gl-bar-padding: 3px;
/*
* Misc
......
......@@ -22,9 +22,10 @@
}
}
.multi-file {
.ide-view {
display: flex;
height: calc(100vh - 145px);
height: calc(100vh - #{$header-height});
color: $almost-black;
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
......@@ -35,12 +36,47 @@
}
}
.with-performance-bar .ide-view {
height: calc(100vh - #{$header-height});
}
.ide-file-list {
flex: 1;
overflow: scroll;
.file {
cursor: pointer;
&.file-open {
background: $white-normal;
}
.repo-file-name {
white-space: nowrap;
text-overflow: ellipsis;
}
.unsaved-icon {
color: $indigo-700;
float: right;
font-size: smaller;
line-height: 20px;
}
.repo-new-btn {
display: none;
margin-top: -4px;
margin-bottom: -4px;
}
&:hover {
.repo-new-btn {
display: block;
}
.unsaved-icon {
display: none;
}
}
}
a {
......@@ -55,10 +91,9 @@
.multi-file-table-name,
.multi-file-table-col-commit-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
overflow: visible;
max-width: 0;
padding: 6px 12px;
}
.multi-file-table-name {
......@@ -66,6 +101,7 @@
}
.multi-file-table-col-commit-message {
white-space: nowrap;
width: 50%;
}
......@@ -79,7 +115,7 @@
.multi-file-tabs {
display: flex;
overflow: scroll;
overflow-x: auto;
background-color: $white-normal;
box-shadow: inset 0 -1px $white-dark;
......@@ -128,9 +164,38 @@
height: 0;
}
.blob-editor-container {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
justify-content: center;
.vertical-center {
min-height: auto;
}
}
.multi-file-editor-holder {
height: 100%;
}
.multi-file-editor-btn-group {
padding: $grid-size;
padding: $gl-bar-padding $gl-padding;
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
background: $white-light;
}
.ide-status-bar {
padding: $gl-bar-padding $gl-padding;
background: $white-light;
display: flex;
justify-content: space-between;
svg {
vertical-align: middle;
}
}
// Not great, but this is to deal with our current output
......@@ -138,10 +203,6 @@
height: 100%;
overflow: scroll;
.blob-viewer {
height: 100%;
}
.file-content.code {
display: flex;
......@@ -162,18 +223,101 @@
}
}
.file-content.blob-no-preview {
a {
margin-left: auto;
margin-right: auto;
}
}
.multi-file-commit-panel {
display: flex;
flex-direction: column;
height: 100%;
width: 290px;
padding: $gl-padding;
padding: 0;
background-color: $gray-light;
border-left: 1px solid $white-dark;
.projects-sidebar {
display: flex;
flex-direction: column;
}
.multi-file-commit-panel-inner {
display: flex;
flex: 1;
flex-direction: column;
}
.multi-file-commit-panel-inner-scroll {
display: flex;
flex: 1;
flex-direction: column;
overflow: auto;
}
&.is-collapsed {
width: 60px;
padding: 0;
.multi-file-commit-list {
padding-top: $gl-padding;
overflow: hidden;
}
.multi-file-context-bar-icon {
align-items: center;
svg {
float: none;
margin: 0;
}
}
}
.branch-container {
border-left: 4px solid $indigo-700;
margin-bottom: $gl-bar-padding;
}
.branch-header {
background: $white-dark;
display: flex;
}
.branch-header-title {
flex: 1;
padding: $grid-size $gl-padding;
color: $indigo-700;
font-weight: $gl-font-weight-bold;
svg {
vertical-align: middle;
}
}
.branch-header-btns {
padding: $gl-vert-padding $gl-padding;
}
.left-collapse-btn {
display: none;
background: $gray-light;
text-align: left;
border-top: 1px solid $white-dark;
svg {
vertical-align: middle;
}
}
}
.multi-file-context-bar-icon {
padding: 10px;
svg {
margin-right: 10px;
float: left;
}
}
......@@ -186,9 +330,9 @@
.multi-file-commit-panel-header {
display: flex;
align-items: center;
padding: 0 0 12px;
margin-bottom: 12px;
border-bottom: 1px solid $white-dark;
padding: $gl-btn-padding 0;
&.is-collapsed {
border-bottom: 1px solid $white-dark;
......@@ -197,23 +341,33 @@
margin-left: auto;
margin-right: auto;
}
.multi-file-commit-panel-collapse-btn {
margin-right: auto;
margin-left: auto;
border-left: 0;
}
}
}
.multi-file-commit-panel-collapse-btn {
padding-top: 0;
padding-bottom: 0;
margin-left: auto;
font-size: 20px;
.multi-file-commit-panel-header-title {
display: flex;
flex: 1;
padding: $gl-btn-padding;
&.is-collapsed {
margin-right: auto;
svg {
margin-right: $gl-btn-padding;
}
}
.multi-file-commit-panel-collapse-btn {
border-left: 1px solid $white-dark;
}
.multi-file-commit-list {
flex: 1;
overflow: scroll;
overflow: auto;
padding: $gl-padding;
}
.multi-file-commit-list-item {
......@@ -244,7 +398,7 @@
}
.multi-file-commit-form {
padding-top: 12px;
padding: $gl-padding;
border-top: 1px solid $white-dark;
}
......@@ -295,3 +449,40 @@
}
}
}
.ide-loading {
display: flex;
height: 100vh;
align-items: center;
justify-content: center;
}
.ide-empty-state {
display: flex;
height: 100vh;
align-items: center;
justify-content: center;
}
.repo-new-btn {
.dropdown-toggle svg {
margin-top: -2px;
margin-bottom: 2px;
}
.dropdown-menu {
left: auto;
right: 0;
label {
font-weight: $gl-font-weight-normal;
padding: 5px 8px;
margin-bottom: 0;
}
}
}
.ide-flash-container.flash-container {
margin-top: $header-height;
margin-bottom: 0;
}
class IdeController < ApplicationController
layout 'nav_only'
def index
end
end
......@@ -306,7 +306,7 @@ module ApplicationHelper
cookies["sidebar_collapsed"] == "true"
end
def show_new_repo?
def show_new_ide?
cookies["new_repo"] == "true" && body_data_page != 'projects:show'
end
......
......@@ -8,7 +8,7 @@ module BlobHelper
%w(credits changelog news copying copyright license authors)
end
def edit_path(project = @project, ref = @ref, path = @path, options = {})
def edit_blob_path(project = @project, ref = @ref, path = @path, options = {})
project_edit_blob_path(project,
tree_join(ref, path),
options[:link_opts])
......@@ -26,10 +26,10 @@ module BlobHelper
button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
# This condition applies to anonymous or users who can edit directly
elsif !current_user || (current_user && can_modify_blob?(blob, project, ref))
link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm"
link_to 'Edit', edit_blob_path(project, ref, path, options), class: "#{common_classes} btn-sm"
elsif current_user && can?(current_user, :fork_project, project)
continue_params = {
to: edit_path(project, ref, path, options),
to: edit_blob_path(project, ref, path, options),
notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now
}
......@@ -41,6 +41,43 @@ module BlobHelper
end
end
def ide_edit_path(project = @project, ref = @ref, path = @path, options = {})
"#{ide_path}/project#{edit_blob_path(project, ref, path, options)}"
end
def ide_edit_text
"#{_('Multi Edit')} <span class='label label-primary'>#{_('Beta')}</span>".html_safe
end
def ide_blob_link(project = @project, ref = @ref, path = @path, options = {})
return unless show_new_ide?
blob = options.delete(:blob)
blob ||= project.repository.blob_at(ref, path) rescue nil
return unless blob && blob.readable_text?
common_classes = "btn js-edit-ide #{options[:extra_class]}"
if !on_top_of_branch?(project, ref)
button_tag ide_edit_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' }
# This condition applies to anonymous or users who can edit directly
elsif current_user && can_modify_blob?(blob, project, ref)
link_to ide_edit_text, ide_edit_path(project, ref, path, options), class: "#{common_classes} btn-sm"
elsif current_user && can?(current_user, :fork_project, project)
continue_params = {
to: ide_edit_path(project, ref, path, options),
notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now
}
fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params)
button_tag ide_edit_text,
class: common_classes,
data: { fork_path: fork_path }
end
end
def modify_file_link(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:)
return unless current_user
......
- page_title 'IDE'
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'ide'
.ide-flash-container.flash-container
#ide.ide-loading
.text-center
= icon('spinner spin 2x')
%h2.clgray= _('IDE Loading ...')
!!! 5
%html{ lang: I18n.locale, class: page_class }
= render "layouts/head"
%body{ class: "#{user_application_theme} #{@body_class}", data: { page: body_data_page } }
= render 'peek/bar'
= render "layouts/header/default"
= render 'shared/outdated_browser'
.mobile-overlay
.alert-wrapper
= render "layouts/broadcast"
= yield :flash_message
= render "layouts/flash"
= yield
......@@ -7,7 +7,7 @@
.nav-block
= render 'projects/tree/tree_header', tree: @tree
- if !show_new_repo? && commit
- if commit
= render 'shared/commit_well', commit: commit, ref: ref, project: project
= render 'projects/tree/tree_content', tree: @tree, content_url: content_url
......@@ -12,6 +12,7 @@
.btn-group{ role: "group" }<
= edit_blob_link
= ide_blob_link
- if current_user
= replace_blob_link
= delete_blob_link
......
......@@ -6,21 +6,14 @@
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'blob'
- if show_new_repo?
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'repo'
= render 'projects/last_push'
%div{ class: container_class }
- if show_new_repo?
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_blob_path(@project, @id)
- else
#tree-holder.tree-holder
= render 'blob', blob: @blob
#tree-holder.tree-holder
= render 'blob', blob: @blob
- if can_modify_blob?(@blob)
= render 'projects/blob/remove'
- title = "Replace #{@blob.name}"
= render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put
- title = "Replace #{@blob.name}"
= render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put
.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path }
.table-holder
%table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
%thead
%tr
%th= s_('ProjectFileTree|Name')
%th.hidden-xs
.pull-left= _('Last commit')
%th.text-right= _('Last update')
- if @path.present?
%tr.tree-item
%td.tree-item-file-name
= link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10'
%td
%td.hidden-xs
= render_tree(tree)
- if tree.readme
= render "projects/tree/readme", readme: tree.readme
- if can_edit_tree?
= render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
= render 'projects/blob/new_dir'
- if on_top_of_branch?
- addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' }
- else
- addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' }
%ul.breadcrumb.repo-breadcrumb
%li
= link_to project_tree_path(@project, @ref) do
= @project.path
- path_breadcrumbs do |title, path|
%li
= link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
- if current_user
%li
%a.btn.add-to-tree{ addtotree_toggle_attributes }
= sprite_icon('plus', size: 16, css_class: 'pull-left')
= sprite_icon('arrow-down', size: 16, css_class: 'pull-left')
- if on_top_of_branch?
.add-to-tree-dropdown
%ul.dropdown-menu
- if can_edit_tree?
%li
= link_to project_new_blob_path(@project, @id) do
#{ _('New file') }
%li
= link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
#{ _('Upload file') }
%li
= link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
#{ _('New directory') }
- elsif can?(current_user, :fork_project, @project)
%li
- continue_params = { to: project_new_blob_path(@project, @id),
notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now }
- fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
#{ _('New file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to upload a file again.",
notice_now: edit_in_new_fork_notice_now }
- fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
#{ _('Upload file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to create a new directory again.",
notice_now: edit_in_new_fork_notice_now }
- fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
#{ _('New directory') }
%li.divider
%li
= link_to new_project_branch_path(@project) do
#{ _('New branch') }
%li
= link_to new_project_tag_path(@project) do
#{ _('New tag') }
- content_url = local_assigns.fetch(:content_url, nil)
- if show_new_repo?
= render 'shared/repo/repo', project: @project, content_url: content_url
- else
= render 'projects/tree/old_tree_content', tree: tree
.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path }
.table-holder
%table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
%thead
%tr
%th= s_('ProjectFileTree|Name')
%th.hidden-xs
.pull-left= _('Last commit')
%th.text-right= _('Last update')
- if @path.present?
%tr.tree-item
%td.tree-item-file-name
= link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10'
%td
%td.hidden-xs
= render_tree(tree)
- if tree.readme
= render "projects/tree/readme", readme: tree.readme
- if can_edit_tree?
= render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
= render 'projects/blob/new_dir'
......@@ -2,16 +2,78 @@
.tree-ref-holder
= render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
- if show_new_repo? && can_push_branch?(@project, @ref)
.js-new-dropdown
- else
= render 'projects/tree/old_tree_header'
- if on_top_of_branch?
- addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' }
- else
- addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' }
%ul.breadcrumb.repo-breadcrumb
%li
= link_to project_tree_path(@project, @ref) do
= @project.path
- path_breadcrumbs do |title, path|
%li
= link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
- if current_user
%li
%a.btn.add-to-tree{ addtotree_toggle_attributes }
= sprite_icon('plus', size: 16, css_class: 'pull-left')
= sprite_icon('arrow-down', size: 16, css_class: 'pull-left')
- if on_top_of_branch?
.add-to-tree-dropdown
%ul.dropdown-menu
- if can_edit_tree?
%li
= link_to project_new_blob_path(@project, @id) do
#{ _('New file') }
%li
= link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
#{ _('Upload file') }
%li
= link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
#{ _('New directory') }
- elsif can?(current_user, :fork_project, @project)
%li
- continue_params = { to: project_new_blob_path(@project, @id),
notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now }
- fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
#{ _('New file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to upload a file again.",
notice_now: edit_in_new_fork_notice_now }
- fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
#{ _('Upload file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to create a new directory again.",
notice_now: edit_in_new_fork_notice_now }
- fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
#{ _('New directory') }
%li.divider
%li
= link_to new_project_branch_path(@project) do
#{ _('New branch') }
%li
= link_to new_project_tag_path(@project) do
#{ _('New tag') }
.tree-controls
- if show_new_repo?
.editable-mode
- else
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
- if show_new_ide?
= succeed " " do
= link_to ide_edit_path(@project, @id), class: 'btn btn-default' do
= ide_edit_text
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
= render 'projects/find_file_link'
......
......@@ -6,11 +6,6 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
- if show_new_repo?
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'repo'
%div{ class: [(container_class unless show_new_repo?), ("limit-container-width" unless fluid_layout)] }
%div{ class: [(container_class), ("limit-container-width" unless fluid_layout)] }
= render 'projects/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
- show_create = local_assigns.fetch(:show_create, false)
- show_new_branch_form = show_new_repo? && show_create && can?(current_user, :push_code, @project)
- show_new_branch_form = show_new_ide? && show_create && can?(current_user, :push_code, @project)
- dropdown_toggle_text = @ref || @project.default_branch
= form_tag switch_project_refs_path(@project), method: :get, class: "project-refs-form" do
= hidden_field_tag :destination, destination
......
- @no_container = true;
#repo{ data: { root: @path.empty?.to_s,
root_url: project_tree_path(project),
url: content_url,
current_branch: @ref,
ref: @commit.id,
project_name: project.name,
project_url: project_path(project),
project_id: project.id,
new_merge_request_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '' }),
can_commit: (!!can_push_branch?(project, @ref)).to_s,
on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s,
current_path: @path } }
---
title: Adds the multi file editor as a new beta feature
merge_request: 15430
author:
type: feature
......@@ -43,6 +43,8 @@ Rails.application.routes.draw do
get 'liveness' => 'health#liveness'
get 'readiness' => 'health#readiness'
post 'storage_check' => 'health#storage_check'
get 'ide' => 'ide#index'
get 'ide/*vueroute' => 'ide#index', format: false
resources :metrics, only: [:index]
mount Peek::Railtie => '/peek'
......
......@@ -70,7 +70,7 @@ var config = {
protected_branches: './protected_branches',
protected_tags: './protected_tags',
registry_list: './registry/index.js',
repo: './repo/index.js',
ide: './ide/index.js',
sidebar: './sidebar/sidebar_bundle.js',
schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js',
schedules_index: './pipeline_schedules/pipeline_schedules_index_bundle.js',
......@@ -204,7 +204,7 @@ var config = {
'pipelines',
'pipelines_details',
'registry_list',
'repo',
'ide',
'schedule_form',
'schedules_index',
'sidebar',
......
require 'rails_helper'
feature 'Ref switcher', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
before do
project.team << [user, :master]
set_cookie('new_repo', 'true')
sign_in(user)
visit project_tree_path(project, 'master')
end
it 'allow user to change ref by enter key' do
click_button 'master'
wait_for_requests
page.within '.project-refs-form' do
input = find('input[type="search"]')
input.set 'binary'
wait_for_requests
expect(find('.dropdown-content ul')).to have_selector('li', count: 7)
page.within '.dropdown-content ul' do
input.native.send_keys :enter
end
end
expect(page).to have_title 'add-pdf-text-binary'
end
it "user selects ref with special characters" do
click_button 'master'
wait_for_requests
page.within '.project-refs-form' do
page.fill_in 'Search branches and tags', with: "'test'"
click_link "'test'"
end
expect(page).to have_title "'test'"
end
context "create branch" do
let(:input) { find('.js-new-branch-name') }
before do
click_button 'master'
wait_for_requests
page.within '.project-refs-form' do
find(".dropdown-footer-list a").click
end
end
it "shows error message for the invalid branch name" do
input.set 'foo bar'
click_button('Create')
wait_for_requests
expect(page).to have_content 'Branch name is invalid'
end
it "should create new branch properly" do
input.set 'new-branch-name'
click_button('Create')
wait_for_requests
expect(find('.js-project-refs-dropdown')).to have_content 'new-branch-name'
end
it "should create new branch by Enter key" do
input.set 'new-branch-name-2'
input.native.send_keys :enter
wait_for_requests
expect(find('.js-project-refs-dropdown')).to have_content 'new-branch-name-2'
end
end
end
......@@ -13,6 +13,14 @@ feature 'Multi-file editor new directory', :js do
visit project_tree_path(project, :master)
wait_for_requests
click_link('Multi Edit')
wait_for_requests
end
after do
set_cookie('new_repo', 'false')
end
it 'creates directory in current directory' do
......@@ -21,17 +29,29 @@ feature 'Multi-file editor new directory', :js do
click_link('New directory')
page.within('.modal') do
find('.form-control').set('foldername')
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
find('.multi-file-commit-panel-collapse-btn').click
fill_in('commit-message', with: 'commit message')
fill_in('commit-message', with: 'commit message ide')
click_button('Commit')
expect(page).to have_selector('td', text: 'commit message')
expect(page).to have_content('folder name')
end
end
......@@ -13,6 +13,14 @@ feature 'Multi-file editor new file', :js do
visit project_tree_path(project, :master)
wait_for_requests
click_link('Multi Edit')
wait_for_requests
end
after do
set_cookie('new_repo', 'false')
end
it 'creates file in current directory' do
......@@ -21,17 +29,19 @@ feature 'Multi-file editor new file', :js do
click_link('New file')
page.within('.modal') do
find('.form-control').set('filename')
find('.form-control').set('file name')
click_button('Create file')
end
wait_for_requests
find('.multi-file-commit-panel-collapse-btn').click
fill_in('commit-message', with: 'commit message')
fill_in('commit-message', with: 'commit message ide')
click_button('Commit')
expect(page).to have_selector('td', text: 'commit message')
expect(page).to have_content('file name')
end
end
......@@ -15,6 +15,14 @@ feature 'Multi-file editor upload file', :js do
visit project_tree_path(project, :master)
wait_for_requests
click_link('Multi Edit')
wait_for_requests
end
after do
set_cookie('new_repo', 'false')
end
it 'uploads text file' do
......@@ -41,6 +49,5 @@ feature 'Multi-file editor upload file', :js do
expect(page).to have_selector('.multi-file-tab', text: 'dk.png')
expect(page).not_to have_selector('.monaco-editor')
expect(page).to have_content('The source could not be displayed for this temporary file.')
end
end
import Vue from 'vue';
import store from '~/repo/stores';
import listCollapsed from '~/repo/components/commit_sidebar/list_collapsed.vue';
import store from '~/ide/stores';
import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { file } from '../../helpers';
......
import Vue from 'vue';
import listItem from '~/repo/components/commit_sidebar/list_item.vue';
import listItem from '~/ide/components/commit_sidebar/list_item.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
import { file } from '../../helpers';
......
import Vue from 'vue';
import store from '~/repo/stores';
import commitSidebarList from '~/repo/components/commit_sidebar/list.vue';
import store from '~/ide/stores';
import commitSidebarList from '~/ide/components/commit_sidebar/list.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { file } from '../../helpers';
......@@ -13,8 +13,11 @@ describe('Multi-file editor commit sidebar list', () => {
vm = createComponentWithStore(Component, store, {
title: 'Staged',
fileList: [],
collapsed: false,
}).$mount();
});
vm.$store.state.rightPanelCollapsed = false;
vm.$mount();
});
afterEach(() => {
......@@ -43,30 +46,14 @@ describe('Multi-file editor commit sidebar list', () => {
describe('collapsed', () => {
beforeEach((done) => {
vm.collapsed = true;
vm.$store.state.rightPanelCollapsed = true;
Vue.nextTick(done);
});
it('adds collapsed class', () => {
expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull();
});
it('hides list', () => {
expect(vm.$el.querySelector('.list-unstyled')).toBeNull();
expect(vm.$el.querySelector('.help-block')).toBeNull();
});
it('hides collapse button', () => {
expect(vm.$el.querySelector('.multi-file-commit-panel-collapse-btn')).toBeNull();
});
});
it('clicking toggle collapse button emits toggle event', () => {
spyOn(vm, '$emit');
vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click();
expect(vm.$emit).toHaveBeenCalledWith('toggleCollapsed');
});
});
import Vue from 'vue';
import store from '~/ide/stores';
import ideContextBar from '~/ide/components/ide_context_bar.vue';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
describe('Multi-file editor right context bar', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(ideContextBar);
vm = createComponentWithStore(Component, store);
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();
});
it('shows correct icon', () => {
expect(vm.currentIcon).toBe('angle-double-left');
});
});
it('clicking toggle collapse button collapses the bar', () => {
spyOn(vm, 'setPanelCollapsedStatus').and.returnValue(Promise.resolve());
vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click();
expect(vm.setPanelCollapsedStatus).toHaveBeenCalledWith({
side: 'right',
collapsed: true,
});
});
});
import Vue from 'vue';
import store from '~/repo/stores';
import repoSidebar from '~/repo/components/repo_sidebar.vue';
import store from '~/ide/stores';
import ideRepoTree from '~/ide/components/ide_repo_tree.vue';
import { file, resetStore } from '../helpers';
describe('RepoSidebar', () => {
describe('IdeRepoTree', () => {
let vm;
beforeEach(() => {
const RepoSidebar = Vue.extend(repoSidebar);
const IdeRepoTree = Vue.extend(ideRepoTree);
vm = new RepoSidebar({
vm = new IdeRepoTree({
store,
propsData: {
treeId: 'abcproject/mybranch',
},
});
vm.$store.state.currentBranch = 'master';
vm.$store.state.isRoot = true;
vm.$store.state.tree.push(file());
vm.$store.state.trees['abcproject/mybranch'] = {
tree: [file()],
};
vm.$mount();
});
......@@ -26,13 +32,9 @@ describe('RepoSidebar', () => {
});
it('renders a sidebar', () => {
const thead = vm.$el.querySelector('thead');
const tbody = vm.$el.querySelector('tbody');
expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy();
expect(thead.querySelector('.name').textContent.trim()).toEqual('Name');
expect(thead.querySelector('.last-commit').textContent.trim()).toEqual('Last commit');
expect(thead.querySelector('.last-update').textContent.trim()).toEqual('Last update');
expect(tbody.querySelector('.repo-file-options')).toBeFalsy();
expect(tbody.querySelector('.prev-directory')).toBeFalsy();
expect(tbody.querySelector('.loading-file')).toBeFalsy();
......@@ -40,7 +42,6 @@ describe('RepoSidebar', () => {
});
it('renders 5 loading files if tree is loading', (done) => {
vm.$store.state.tree = [];
vm.$store.state.loading = true;
Vue.nextTick(() => {
......
import Vue from 'vue';
import store from '~/ide/stores';
import ideSidebar from '~/ide/components/ide_side_bar.vue';
import { resetStore } from '../helpers';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
describe('IdeSidebar', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(ideSidebar);
vm = createComponentWithStore(Component, store).$mount();
vm.$store.state.leftPanelCollapsed = false;
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders a sidebar', () => {
expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
});
describe('collapsed', () => {
beforeEach((done) => {
vm.$store.state.leftPanelCollapsed = true;
Vue.nextTick(done);
});
it('adds collapsed class', () => {
expect(vm.$el.classList).toContain('is-collapsed');
});
it('shows correct icon', () => {
expect(vm.currentIcon).toBe('angle-double-right');
});
});
});
import Vue from 'vue';
import store from '~/repo/stores';
import repo from '~/repo/components/repo.vue';
import store from '~/ide/stores';
import ide from '~/ide/components/ide.vue';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { file, resetStore } from '../helpers';
describe('repo component', () => {
describe('ide component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(repo);
const Component = Vue.extend(ide);
vm = createComponentWithStore(Component, store).$mount();
});
......@@ -24,7 +24,9 @@ describe('repo component', () => {
});
it('renders panel right when files are open', (done) => {
vm.$store.state.tree.push(file());
vm.$store.state.trees['abcproject/mybranch'] = {
tree: [file()],
};
Vue.nextTick(() => {
expect(vm.$el.querySelector('.panel-right')).toBeNull();
......
import Vue from 'vue';
import store from '~/repo/stores';
import newBranchForm from '~/repo/components/new_branch_form.vue';
import store from '~/ide/stores';
import newBranchForm from '~/ide/components/new_branch_form.vue';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
......
import Vue from 'vue';
import store from '~/repo/stores';
import newDropdown from '~/repo/components/new_dropdown/index.vue';
import store from '~/ide/stores';
import newDropdown from '~/ide/components/new_dropdown/index.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { resetStore } from '../../helpers';
......@@ -10,8 +10,12 @@ describe('new dropdown component', () => {
beforeEach(() => {
const component = Vue.extend(newDropdown);
vm = createComponentWithStore(component, store);
vm = createComponentWithStore(component, store, {
branch: 'master',
path: '',
});
vm.$store.state.currentProjectId = 'abcproject';
vm.$store.state.path = '';
vm.$mount();
......@@ -23,9 +27,10 @@ describe('new dropdown component', () => {
resetStore(vm.$store);
});
it('renders new file and new directory links', () => {
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('New directory');
expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe('Upload file');
expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe('New directory');
});
describe('createNewItem', () => {
......@@ -36,7 +41,7 @@ describe('new dropdown component', () => {
});
it('sets modalType to tree when new directory is clicked', () => {
vm.$el.querySelectorAll('a')[1].click();
vm.$el.querySelectorAll('a')[2].click();
expect(vm.modalType).toBe('tree');
});
......
import Vue from 'vue';
import store from '~/repo/stores';
import modal from '~/repo/components/new_dropdown/modal.vue';
import store from '~/ide/stores';
import service from '~/ide/services';
import modal from '~/ide/components/new_dropdown/modal.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { file, resetStore } from '../../helpers';
describe('new file modal component', () => {
const Component = Vue.extend(modal);
let vm;
let projectTree;
beforeEach(() => {
spyOn(service, 'getProjectData').and.returnValue(Promise.resolve({
data: {
id: '123',
},
}));
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
commit: {
id: '123branch',
},
}));
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' }],
}),
}));
});
afterEach(() => {
vm.$destroy();
......@@ -17,12 +47,26 @@ describe('new file modal component', () => {
['tree', 'blob'].forEach((type) => {
describe(type, () => {
beforeEach(() => {
store.state.projects.abcproject = {
web_url: '',
};
store.state.trees = [];
store.state.trees['abcproject/mybranch'] = {
tree: [],
};
projectTree = store.state.trees['abcproject/mybranch'];
store.state.currentProjectId = 'abcproject';
vm = createComponentWithStore(Component, store, {
type,
branchId: 'master',
path: '',
}).$mount();
parent: projectTree,
});
vm.entryName = 'testing';
vm.$mount();
});
it(`sets modal title as ${type}`, () => {
......@@ -50,6 +94,9 @@ describe('new file modal component', () => {
vm.createEntryInStore();
expect(vm.createTempEntry).toHaveBeenCalledWith({
projectId: 'abcproject',
branchId: 'master',
parent: projectTree,
name: 'testing',
type,
});
......@@ -76,31 +123,18 @@ describe('new file modal component', () => {
});
it('opens newly created file', (done) => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.openFiles.length).toBe(1);
expect(vm.$store.state.openFiles[0].name).toBe(type === 'blob' ? 'testing' : '.gitkeep');
done();
});
});
it(`creates ${type} in the current stores path`, (done) => {
vm.$store.state.path = 'app';
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.tree[0].path).toBe('app/testing');
expect(vm.$store.state.tree[0].name).toBe('testing');
if (type === 'blob') {
vm.createEntryInStore();
if (type === 'tree') {
expect(vm.$store.state.tree[0].tree.length).toBe(1);
}
setTimeout(() => {
expect(vm.$store.state.openFiles.length).toBe(1);
expect(vm.$store.state.openFiles[0].name).toBe(type === 'blob' ? 'testing' : '.gitkeep');
done();
});
} else {
done();
});
}
});
if (type === 'blob') {
......@@ -108,25 +142,27 @@ describe('new file modal component', () => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('testing');
expect(vm.$store.state.tree[0].type).toBe('blob');
expect(vm.$store.state.tree[0].tempFile).toBeTruthy();
const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
expect(baseTree.length).toBe(1);
expect(baseTree[0].name).toBe('testing');
expect(baseTree[0].type).toBe('blob');
expect(baseTree[0].tempFile).toBeTruthy();
done();
});
});
it('does not create temp file when file already exists', (done) => {
vm.$store.state.tree.push(file('testing', '1', type));
const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
baseTree.push(file('testing', '1', type));
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('testing');
expect(vm.$store.state.tree[0].type).toBe('blob');
expect(vm.$store.state.tree[0].tempFile).toBeFalsy();
expect(baseTree.length).toBe(1);
expect(baseTree[0].name).toBe('testing');
expect(baseTree[0].type).toBe('blob');
expect(baseTree[0].tempFile).toBeFalsy();
done();
});
......@@ -135,48 +171,47 @@ describe('new file modal component', () => {
it('creates new tree', () => {
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('testing');
expect(vm.$store.state.tree[0].type).toBe('tree');
expect(vm.$store.state.tree[0].tempFile).toBeTruthy();
expect(vm.$store.state.tree[0].tree.length).toBe(1);
expect(vm.$store.state.tree[0].tree[0].name).toBe('.gitkeep');
const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
expect(baseTree.length).toBe(1);
expect(baseTree[0].name).toBe('testing');
expect(baseTree[0].type).toBe('tree');
expect(baseTree[0].tempFile).toBeTruthy();
});
it('creates multiple trees when entryName has slashes', () => {
vm.entryName = 'app/test';
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('app');
expect(vm.$store.state.tree[0].tree[0].name).toBe('test');
expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep');
const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
expect(baseTree.length).toBe(1);
expect(baseTree[0].name).toBe('app');
});
it('creates tree in existing tree', () => {
vm.$store.state.tree.push(file('app', '1', 'tree'));
const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
baseTree.push(file('app', '1', 'tree'));
vm.entryName = 'app/test';
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('app');
expect(vm.$store.state.tree[0].tempFile).toBeFalsy();
expect(vm.$store.state.tree[0].tree[0].tempFile).toBeTruthy();
expect(vm.$store.state.tree[0].tree[0].name).toBe('test');
expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep');
expect(baseTree.length).toBe(1);
expect(baseTree[0].name).toBe('app');
expect(baseTree[0].tempFile).toBeFalsy();
expect(baseTree[0].tree[0].tempFile).toBeTruthy();
expect(baseTree[0].tree[0].name).toBe('test');
});
it('does not create new tree when already exists', () => {
vm.$store.state.tree.push(file('app', '1', 'tree'));
const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
baseTree.push(file('app', '1', 'tree'));
vm.entryName = 'app';
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('app');
expect(vm.$store.state.tree[0].tempFile).toBeFalsy();
expect(vm.$store.state.tree[0].tree.length).toBe(0);
expect(baseTree.length).toBe(1);
expect(baseTree[0].name).toBe('app');
expect(baseTree[0].tempFile).toBeFalsy();
expect(baseTree[0].tree.length).toBe(0);
});
}
});
......@@ -188,6 +223,8 @@ describe('new file modal component', () => {
vm = createComponentWithStore(Component, store, {
type: 'tree',
projectId: 'abcproject',
branchId: 'master',
path: '',
}).$mount('.js-test');
......
import Vue from 'vue';
import upload from '~/repo/components/new_dropdown/upload.vue';
import store from '~/repo/stores';
import upload from '~/ide/components/new_dropdown/upload.vue';
import store from '~/ide/stores';
import service from '~/ide/services';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { resetStore } from '../../helpers';
describe('new dropdown upload', () => {
let vm;
let projectTree;
beforeEach(() => {
spyOn(service, 'getProjectData').and.returnValue(Promise.resolve({
data: {
id: '123',
},
}));
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
commit: {
id: '123branch',
},
}));
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' }],
}),
}));
const Component = Vue.extend(upload);
store.state.projects.abcproject = {
web_url: '',
};
store.state.currentProjectId = 'abcproject';
store.state.trees = [];
store.state.trees['abcproject/mybranch'] = {
tree: [],
};
projectTree = store.state.trees['abcproject/mybranch'];
vm = createComponentWithStore(Component, store, {
branchId: 'master',
path: '',
parent: projectTree,
});
vm.entryName = 'testing';
vm.$mount();
});
......@@ -65,23 +107,33 @@ describe('new dropdown upload', () => {
vm.createFile(target, file, true);
vm.$nextTick(() => {
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe(file.name);
expect(vm.$store.state.tree[0].content).toBe(target.result);
const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
expect(baseTree.length).toBe(1);
expect(baseTree[0].name).toBe(file.name);
expect(baseTree[0].content).toBe(target.result);
done();
});
});
it('creates new file in path', (done) => {
vm.$store.state.path = 'testing';
const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
const tree = {
type: 'tree',
name: 'testing',
path: 'testing',
tree: [],
};
baseTree.push(tree);
vm.parent = tree;
vm.createFile(target, file, true);
vm.$nextTick(() => {
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe(file.name);
expect(vm.$store.state.tree[0].content).toBe(target.result);
expect(vm.$store.state.tree[0].path).toBe(`testing/${file.name}`);
expect(baseTree.length).toBe(1);
expect(baseTree[0].tree[0].name).toBe(file.name);
expect(baseTree[0].tree[0].content).toBe(target.result);
expect(baseTree[0].tree[0].path).toBe(`testing/${file.name}`);
done();
});
......@@ -91,10 +143,11 @@ describe('new dropdown upload', () => {
vm.createFile(binaryTarget, file, false);
vm.$nextTick(() => {
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe(file.name);
expect(vm.$store.state.tree[0].content).toBe(binaryTarget.result.split('base64,')[1]);
expect(vm.$store.state.tree[0].base64).toBe(true);
const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
expect(baseTree.length).toBe(1);
expect(baseTree[0].name).toBe(file.name);
expect(baseTree[0].content).toBe(binaryTarget.result.split('base64,')[1]);
expect(baseTree[0].base64).toBe(true);
done();
});
......
import Vue from 'vue';
import * as urlUtils from '~/lib/utils/url_utility';
import store from '~/repo/stores';
import service from '~/repo/services';
import repoCommitSection from '~/repo/components/repo_commit_section.vue';
import store from '~/ide/stores';
import service from '~/ide/services';
import repoCommitSection from '~/ide/components/repo_commit_section.vue';
import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper';
import { file, resetStore } from '../helpers';
......@@ -16,6 +16,18 @@ describe('RepoCommitSection', () => {
store,
}).$mount();
comp.$store.state.currentProjectId = 'abcproject';
comp.$store.state.currentBranchId = 'master';
comp.$store.state.projects.abcproject = {
web_url: '',
branches: {
master: {
workingReference: '1',
},
},
};
comp.$store.state.rightPanelCollapsed = false;
comp.$store.state.currentBranch = 'master';
comp.$store.state.openFiles = [file(), file()];
comp.$store.state.openFiles.forEach(f => Object.assign(f, {
......@@ -29,7 +41,19 @@ describe('RepoCommitSection', () => {
beforeEach((done) => {
vm = createComponent();
vm.collapsed = false;
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);
});
......@@ -45,7 +69,6 @@ describe('RepoCommitSection', () => {
const submitCommit = vm.$el.querySelector('form .btn');
expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull();
expect(vm.$el.querySelector('.multi-file-commit-panel-section header').textContent.trim()).toEqual('Staged');
expect(changedFileElements.length).toEqual(2);
changedFileElements.forEach((changedFile, i) => {
......
import Vue from 'vue';
import store from '~/repo/stores';
import repoEditButton from '~/repo/components/repo_edit_button.vue';
import store from '~/ide/stores';
import repoEditButton from '~/ide/components/repo_edit_button.vue';
import { file, resetStore } from '../helpers';
describe('RepoEditButton', () => {
......@@ -32,7 +32,7 @@ describe('RepoEditButton', () => {
vm.$mount();
expect(vm.$el.querySelector('.btn')).not.toBeNull();
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Edit');
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
});
it('renders edit button with cancel text', () => {
......@@ -50,7 +50,7 @@ describe('RepoEditButton', () => {
vm.$el.querySelector('.btn').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Edit');
done();
});
......
import Vue from 'vue';
import store from '~/repo/stores';
import repoEditor from '~/repo/components/repo_editor.vue';
import monacoLoader from '~/repo/monaco_loader';
import store from '~/ide/stores';
import repoEditor from '~/ide/components/repo_editor.vue';
import monacoLoader from '~/ide/monaco_loader';
import { file, resetStore } from '../helpers';
describe('RepoEditor', () => {
......
此差异已折叠。
此差异已折叠。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册