Commit 4423b299 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'master' into 22643-manual-job-page

* master: (68 commits)
  Last push event widget width for fixed layout
  Added 'admin' persona
  Docs: move article Artifactory + GL to subject-related folder
  Mention GitLab Pages when changing username
  Consolidate the docs when changing a repo path
  Add docs for automatic redirects when renaming namespaces
  move "Transfer an existing project into a group" to project docs
  Do not show Vue pagination if only one page
  Resolve "Decouple multi-file editor from file list"
  Update Ruby version to 2.3.6
  Skip projects filter on merge requests search
  Fall back to the `MergeRequestWidgetEntity`
  LDAP extern_uids are not normalized when updated via API
  organise content by subject
  Restore missing language code in datetime_utility.js
  Add support for defining explicit dependencies to QA factories
  Document mounting volumes with Docker-in-Docker
  add missing word to pawel-reduce_cardinality_of_prometheus_metrics.yml
  Use seconds where possible, and convert to milliseconds for Influxdb consumption
  update dispatcher to allow for dynamic imports until webpack plugin is updated
  ...
parents 0b594784 36f47103
image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.5-golang-1.8-git-2.14-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6"
image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.6-golang-1.9-git-2.14-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6"
.dedicated-runner: &dedicated-runner
retry: 1
......
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';
......@@ -111,6 +110,8 @@ import Activities from './activities';
return false;
}
const fail = () => Flash('Error loading dynamic module');
path = page.split(':');
shortcut_handler = null;
......@@ -447,9 +448,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 +466,6 @@ import Activities from './activities';
shortcut_handler = true;
break;
case 'projects:blob:show':
if (UserFeatureHelper.isNewRepoEnabled()) break;
new BlobViewer();
initBlob();
break;
......@@ -545,7 +542,7 @@ import Activities from './activities';
new CILintEditor();
break;
case 'users:show':
new UserCallout();
import('./pages/users/show').then(m => m.default()).catch(fail);
break;
case 'admin:conversational_development_index:show':
new UserCallout();
......
......@@ -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;
......
......@@ -84,9 +84,12 @@ export default (function() {
return _.each(author_commits, (function(_this) {
return function(d) {
_this.redraw_author_commit_info(d);
$(_this.authors[d.author_name].list_item).appendTo("ol");
_this.authors[d.author_name].set_data(d.dates);
return _this.authors[d.author_name].redraw();
if (_this.authors[d.author_name] != null) {
$(_this.authors[d.author_name].list_item).appendTo("ol");
_this.authors[d.author_name].set_data(d.dates);
return _this.authors[d.author_name].redraw();
}
return '';
};
})(this));
};
......@@ -108,10 +111,14 @@ export default (function() {
};
ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) {
var author_commit_info, author_list_item;
author_list_item = $(this.authors[author.author_name].list_item);
author_commit_info = this.format_author_commit_info(author);
return author_list_item.find("span").html(author_commit_info);
var author_commit_info, author_list_item, $author;
$author = this.authors[author.author_name];
if ($author != null) {
author_list_item = $(this.authors[author.author_name].list_item);
author_commit_info = this.format_author_commit_info(author);
return author_list_item.find("span").html(author_commit_info);
}
return '';
};
return ContributorsStatGraph;
......
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, max-len, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, comma-dangle, no-return-assign, prefer-arrow-callback, quotes, prefer-template, newline-per-chained-call, no-else-return, no-shadow */
import _ from 'underscore';
import d3 from 'd3';
import { extent, max } from 'd3-array';
import { select, event as d3Event } from 'd3-selection';
import { scaleTime, scaleLinear } from 'd3-scale';
import { axisLeft, axisBottom } from 'd3-axis';
import { area } from 'd3-shape';
import { brushX } from 'd3-brush';
import { timeParse } from 'd3-time-format';
import { dateTickFormat } from '../lib/utils/tick_formats';
const d3 = { extent, max, select, scaleTime, scaleLinear, axisLeft, axisBottom, area, brushX, timeParse };
const extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
const hasProp = {}.hasOwnProperty;
......@@ -71,8 +79,8 @@ export const ContributorsGraph = (function() {
};
ContributorsGraph.prototype.create_scale = function(width, height) {
this.x = d3.time.scale().range([0, width]).clamp(true);
return this.y = d3.scale.linear().range([height, 0]).nice();
this.x = d3.scaleTime().range([0, width]).clamp(true);
return this.y = d3.scaleLinear().range([height, 0]).nice();
};
ContributorsGraph.prototype.draw_x_axis = function() {
......@@ -124,7 +132,7 @@ export const ContributorsMasterGraph = (function(superClass) {
ContributorsMasterGraph.prototype.parse_dates = function(data) {
var parseDate;
parseDate = d3.time.format("%Y-%m-%d").parse;
parseDate = d3.timeParse("%Y-%m-%d");
return data.forEach(function(d) {
return d.date = parseDate(d.date);
});
......@@ -135,11 +143,10 @@ export const ContributorsMasterGraph = (function(superClass) {
};
ContributorsMasterGraph.prototype.create_axes = function() {
this.x_axis = d3.svg.axis()
this.x_axis = d3.axisBottom()
.scale(this.x)
.orient('bottom')
.tickFormat(dateTickFormat);
return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5);
return this.y_axis = d3.axisLeft().scale(this.y).ticks(5);
};
ContributorsMasterGraph.prototype.create_svg = function() {
......@@ -147,16 +154,16 @@ export const ContributorsMasterGraph = (function(superClass) {
};
ContributorsMasterGraph.prototype.create_area = function(x, y) {
return this.area = d3.svg.area().x(function(d) {
return this.area = d3.area().x(function(d) {
return x(d.date);
}).y0(this.height).y1(function(d) {
d.commits = d.commits || d.additions || d.deletions;
return y(d.commits);
}).interpolate("basis");
});
};
ContributorsMasterGraph.prototype.create_brush = function() {
return this.brush = d3.svg.brush().x(this.x).on("brushend", this.update_content);
return this.brush = d3.brushX(this.x).extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]).on("end", this.update_content);
};
ContributorsMasterGraph.prototype.draw_path = function(data) {
......@@ -168,7 +175,12 @@ export const ContributorsMasterGraph = (function(superClass) {
};
ContributorsMasterGraph.prototype.update_content = function() {
ContributorsGraph.set_x_domain(this.brush.empty() ? this.x_max_domain : this.brush.extent());
// d3Event.selection replaces the function brush.empty() calls
if (d3Event.selection != null) {
ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert));
} else {
ContributorsGraph.set_x_domain(this.x_max_domain);
}
return $("#brush_change").trigger('change');
};
......@@ -226,18 +238,17 @@ export const ContributorsAuthorGraph = (function(superClass) {
};
ContributorsAuthorGraph.prototype.create_axes = function() {
this.x_axis = d3.svg.axis()
this.x_axis = d3.axisBottom()
.scale(this.x)
.orient('bottom')
.ticks(8)
.tickFormat(dateTickFormat);
return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5);
return this.y_axis = d3.axisLeft().scale(this.y).ticks(5);
};
ContributorsAuthorGraph.prototype.create_area = function(x, y) {
return this.area = d3.svg.area().x(function(d) {
return this.area = d3.area().x(function(d) {
var parseDate;
parseDate = d3.time.format("%Y-%m-%d").parse;
parseDate = d3.timeParse("%Y-%m-%d");
return x(parseDate(d));
}).y0(this.height).y1((function(_this) {
return function(d) {
......@@ -247,11 +258,12 @@ export const ContributorsAuthorGraph = (function(superClass) {
return y(0);
}
};
})(this)).interpolate("basis");
})(this));
};
ContributorsAuthorGraph.prototype.create_svg = function() {
this.list_item = d3.selectAll(".person")[0].pop();
var persons = document.querySelectorAll('.person');
this.list_item = persons[persons.length - 1];
return this.svg = d3.select(this.list_item).append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "spark").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")");
};
......
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,
......
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, no-unused-vars, one-var, one-var-declaration-per-line, vars-on-top, max-len */
import _ from 'underscore';
import Cookies from 'js-cookie';
import ContextualSidebar from './contextual_sidebar';
import initFlyOutNav from './fly_out_nav';
(function() {
var hideEndFade;
function hideEndFade($scrollingTabs) {
$scrollingTabs.each(function scrollTabsLoop() {
const $this = $(this);
$this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth'));
});
}
hideEndFade = function($scrollingTabs) {
return $scrollingTabs.each(function() {
var $this;
$this = $(this);
return $this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth'));
});
};
export default function initLayoutNav() {
const contextualSidebar = new ContextualSidebar();
contextualSidebar.bindEvents();
initFlyOutNav();
$(document).on('init.scrolling-tabs', () => {
const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized');
$scrollingTabs.addClass('is-initialized');
hideEndFade($scrollingTabs);
$(window).off('resize.nav').on('resize.nav', function() {
return hideEndFade($scrollingTabs);
});
$scrollingTabs.off('scroll').on('scroll', function(event) {
var $this, currentPosition, maxPosition;
$this = $(this);
currentPosition = $this.scrollLeft();
maxPosition = $this.prop('scrollWidth') - $this.outerWidth();
$(window).on('resize.nav', () => {
hideEndFade($scrollingTabs);
}).trigger('resize.nav');
$scrollingTabs.on('scroll', function tabsScrollEvent() {
const $this = $(this);
const currentPosition = $this.scrollLeft();
const maxPosition = $this.prop('scrollWidth') - $this.outerWidth();
$this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0);
return $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1);
$this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1);
});
$scrollingTabs.each(function () {
var $this = $(this);
var scrollingTabWidth = $this.width();
var $active = $this.find('.active');
var activeWidth = $active.width();
$scrollingTabs.each(function scrollTabsEachLoop() {
const $this = $(this);
const scrollingTabWidth = $this.width();
const $active = $this.find('.active');
const activeWidth = $active.width();
if ($active.length) {
var offset = $active.offset().left + activeWidth;
const offset = $active.offset().left + activeWidth;
if (offset > scrollingTabWidth - 30) {
var scrollLeft = scrollingTabWidth / 2;
scrollLeft = (offset - scrollLeft) - (activeWidth / 2);
const scrollLeft = (offset - (scrollingTabWidth / 2)) - (activeWidth / 2);
$this.scrollLeft(scrollLeft);
}
}
});
});
$(() => {
const contextualSidebar = new ContextualSidebar();
contextualSidebar.bindEvents();
initFlyOutNav();
});
}).call(window);
}).trigger('init.scrolling-tabs');
}
......@@ -2,7 +2,7 @@ import timeago from 'timeago.js';
import dateFormat from 'vendor/date.format';
import { pluralize } from './text_utility';
import {
lang,
languageCode,
s__,
} from '../../locale';
......@@ -24,7 +24,15 @@ export const getDayName = date => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', '
*/
export const formatDate = datetime => dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
/**
* Timeago uses underscores instead of dashes to separate language from country code.
*
* see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales
*/
const timeagoLanguageCode = languageCode().replace(/-/g, '_');
let timeagoInstance;
/**
* Sets a timeago Instance
*/
......@@ -67,8 +75,8 @@ export function getTimeago() {
][index];
};
timeago.register(lang, locale);
timeago.register(`${lang}-remaining`, localeRemaining);
timeago.register(timeagoLanguageCode, locale);
timeago.register(`${timeagoLanguageCode}-remaining`, localeRemaining);
timeagoInstance = timeago();
}
......@@ -83,7 +91,7 @@ export const renderTimeago = ($els) => {
const timeagoEls = $els || document.querySelectorAll('.js-timeago-render');
// timeago.js sets timeouts internally for each timeago value to be updated in real time
getTimeago().render(timeagoEls, lang);
getTimeago().render(timeagoEls, timeagoLanguageCode);
};
/**
......@@ -118,7 +126,7 @@ export const timeFor = (time, expiredLabel) => {
if (new Date(time) < new Date()) {
return expiredLabel || s__('Timeago|Past due');
}
return getTimeago().format(time, `${lang}-remaining`).trim();
return getTimeago().format(time, `${timeagoLanguageCode}-remaining`).trim();
};
export const getDayDifference = (a, b) => {
......
......@@ -41,7 +41,7 @@ import Flash, { removeFlashClickListener } from './flash';
import './gl_dropdown';
import initTodoToggle from './header';
import initImporterStatus from './importer_status';
import './layout_nav';
import initLayoutNav from './layout_nav';
import LazyLoader from './lazy_loader';
import './line_highlighter';
import initLogoAnimation from './logo';
......@@ -89,6 +89,7 @@ $(function () {
var fitSidebarForSize;
initBreadcrumbs();
initLayoutNav();
initImporterStatus();
initTodoToggle();
initLogoAnimation();
......@@ -261,8 +262,6 @@ $(function () {
renderTimeago();
$(document).trigger('init.scrolling-tabs');
$('form.filter-form').on('submit', function (event) {
const link = document.createElement('a');
link.href = this.action;
......
<script>
import d3 from 'd3';
import { scaleLinear, scaleTime } from 'd3-scale';
import { axisLeft, axisBottom } from 'd3-axis';
import { max, extent } from 'd3-array';
import { select } from 'd3-selection';
import GraphLegend from './graph/legend.vue';
import GraphFlag from './graph/flag.vue';
import GraphDeployment from './graph/deployment.vue';
......@@ -7,10 +10,12 @@
import MonitoringMixin from '../mixins/monitoring_mixins';
import eventHub from '../event_hub';
import measurements from '../utils/measurements';
import { timeScaleFormat, bisectDate } from '../utils/date_time_formatters';
import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters';
import createTimeSeries from '../utils/multiple_time_series';
import bp from '../../breakpoints';
const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select };
export default {
props: {
graphData: {
......@@ -156,25 +161,22 @@
this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20;
}
const axisXScale = d3.time.scale()
const axisXScale = d3.scaleTime()
.range([0, this.graphWidth - 70]);
const axisYScale = d3.scale.linear()
const axisYScale = d3.scaleLinear()
.range([this.graphHeight - this.graphHeightOffset, 0]);
const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []);
axisXScale.domain(d3.extent(allValues, d => d.time));
axisYScale.domain([0, d3.max(allValues.map(d => d.value))]);
const xAxis = d3.svg.axis()
const xAxis = d3.axisBottom()
.scale(axisXScale)
.ticks(d3.time.minute, 60)
.tickFormat(timeScaleFormat)
.orient('bottom');
.tickFormat(timeScaleFormat);
const yAxis = d3.svg.axis()
const yAxis = d3.axisLeft()
.scale(axisYScale)
.ticks(measurements.yTicks)
.orient('left');
.ticks(measurements.yTicks);
d3.select(this.$refs.baseSvg).select('.x-axis').call(xAxis);
......
import d3 from 'd3';
import { timeFormat as time } from 'd3-time-format';
import { timeSecond, timeMinute, timeHour, timeDay, timeMonth, timeYear } from 'd3-time';
import { bisector } from 'd3-array';
export const dateFormat = d3.time.format('%b %-d, %Y');
export const dateFormatWithName = d3.time.format('%a, %b %-d');
export const timeFormat = d3.time.format('%-I:%M%p');
const d3 = { time, bisector, timeSecond, timeMinute, timeHour, timeDay, timeMonth, timeYear };
export const dateFormat = d3.time('%b %-d, %Y');
export const timeFormat = d3.time('%-I:%M%p');
export const dateFormatWithName = d3.time('%a, %b %-d');
export const bisectDate = d3.bisector(d => d.time).left;
export const timeScaleFormat = d3.time.format.multi([
['.%L', d => d.getMilliseconds()],
[':%S', d => d.getSeconds()],
['%-I:%M', d => d.getMinutes()],
['%-I %p', d => d.getHours()],
['%a %-d', d => d.getDay() && d.getDate() !== 1],
['%b %-d', d => d.getDate() !== 1],
['%B', d => d.getMonth()],
['%Y', () => true],
]);
export function timeScaleFormat(date) {
let formatFunction;
if (d3.timeSecond(date) < date) {
formatFunction = d3.time('.%L');
} else if (d3.timeMinute(date) < date) {
formatFunction = d3.time(':%S');
} else if (d3.timeHour(date) < date) {
formatFunction = d3.time('%-I:%M');
} else if (d3.timeDay(date) < date) {
formatFunction = d3.time('%-I %p');
} else if (d3.timeWeek(date) < date) {
formatFunction = d3.time('%a %d');
} else if (d3.timeMonth(date) < date) {
formatFunction = d3.time('%b %d');
} else if (d3.timeYear(date) < date) {
formatFunction = d3.time('%B');
} else {
formatFunction = d3.time('%Y');
}
return formatFunction(date);
}
import d3 from 'd3';
import _ from 'underscore';
import { scaleLinear, scaleTime } from 'd3-scale';
import { line, area, curveLinear } from 'd3-shape';
import { extent, max } from 'd3-array';
import { timeMinute } from 'd3-time';
const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute };
const defaultColorPalette = {
blue: ['#1f78d1', '#8fbce8'],
......@@ -38,27 +43,27 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
let lineColor = '';
let areaColor = '';
const timeSeriesScaleX = d3.time.scale()
const timeSeriesScaleX = d3.scaleTime()
.range([0, graphWidth - 70]);
const timeSeriesScaleY = d3.scale.linear()
const timeSeriesScaleY = d3.scaleLinear()
.range([graphHeight - graphHeightOffset, 0]);
timeSeriesScaleX.domain(xDom);
timeSeriesScaleX.ticks(d3.time.minute, 60);
timeSeriesScaleX.ticks(d3.timeMinute, 60);
timeSeriesScaleY.domain(yDom);
const defined = d => !isNaN(d.value) && d.value != null;
const lineFunction = d3.svg.line()
const lineFunction = d3.line()
.defined(defined)
.interpolate('linear')
.curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
.x(d => timeSeriesScaleX(d.time))
.y(d => timeSeriesScaleY(d.value));
const areaFunction = d3.svg.area()
const areaFunction = d3.area()
.defined(defined)
.interpolate('linear')
.curve(d3.curveLinear)
.x(d => timeSeriesScaleX(d.time))
.y0(graphHeight - graphHeightOffset)
.y1(d => timeSeriesScaleY(d.value));
......
......@@ -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 UserCallout from '~/user_callout';
export default () => new UserCallout();
......@@ -117,12 +117,10 @@
}());
markdownPreview = new window.MarkdownPreview();
previewButtonSelector = '.js-md-preview-button';
writeButtonSelector = '.js-md-write-button';
lastTextareaPreviewed = null;
const markdownToolbar = $('.md-header-toolbar');
$.fn.setupMarkdownPreview = function () {
var $form = $(this);
......@@ -146,6 +144,7 @@
// toggle content
$form.find('.md-write-holder').hide();
$form.find('.md-preview-holder').show();
markdownToolbar.removeClass('active');
markdownPreview.showPreview($form);
});
......@@ -167,6 +166,7 @@
$form.find('.md-write-holder').show();
$form.find('textarea.markdown-area').focus();
$form.find('.md-preview-holder').hide();
markdownToolbar.addClass('active');
markdownPreview.hideReferencedCommands($form);
});
......
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,
});
},
};
import _ from 'underscore';
import d3 from 'd3';
import { scaleLinear, scaleThreshold } from 'd3-scale';
import { select } from 'd3-selection';
import { getDayName, getDayDifference } from '../lib/utils/datetime_utility';
const d3 = { select, scaleLinear, scaleThreshold };
const LOADING_HTML = `
<div class="text-center">
<i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i>
......@@ -28,7 +31,7 @@ function formatTooltipText({ date, count }) {
return `${contribText}<br />${dateDayName} ${dateText}`;
}
const initColorKey = () => d3.scale.linear().range(['#acd5f2', '#254e77']).domain([0, 3]);
const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]);
export default class ActivityCalendar {
constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0) {
......@@ -205,7 +208,7 @@ export default class ActivityCalendar {
initColor() {
const colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
return d3.scale.threshold().domain([0, 10, 20, 30]).range(colorRange);
return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange);
}
clickDay(stamp) {
......
......@@ -72,7 +72,9 @@
Preview
</a>
</li>
<li class="md-header-toolbar">
<li
class="md-header-toolbar"
:class="{ active: !previewMarkdown }">
<toolbar-button
tag="**"
button-title="Add bold text"
......
<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>
......@@ -122,11 +122,17 @@ export default {
return items;
},
showPagination() {
return this.pageInfo.totalPages > 1;
},
},
};
</script>
<template>
<div class="gl-pagination">
<div
v-if="showPagination"
class="gl-pagination"
>
<ul class="pagination clearfix">
<li
v-for="item in getItems"
......
......@@ -23,7 +23,6 @@
.context-header {
position: relative;
margin-right: 2px;
width: $contextual-sidebar-width;
a {
transition: padding $sidebar-transition-duration;
......
......@@ -74,7 +74,7 @@
}
.md-header-tab {
@media(max-width: $screen-xs-max) {
@media (max-width: $screen-xs-max) {
flex: 1;
width: 100%;
border-bottom: 1px solid $border-color;
......@@ -82,16 +82,23 @@
}
}
.md-header-toolbar {
margin-left: auto;
.nav-links {
li.md-header-toolbar {
margin-left: auto;
display: none;
@media(max-width: $screen-xs-max) {
flex: none;
display: flex;
justify-content: center;
width: 100%;
padding-top: $gl-padding-top;
padding-bottom: $gl-padding-top;
&.active {
display: block;
@media (max-width: $screen-xs-max) {
flex: none;
display: flex;
justify-content: center;
width: 100%;
padding-top: $gl-padding-top;
padding-bottom: $gl-padding-top;
}
}
}
}
......@@ -175,7 +182,7 @@
margin-left: $gl-padding;
margin-right: -5px;
@media(max-width: $screen-xs-max) {
@media (max-width: $screen-xs-max) {
margin-left: 0;
margin-right: 0;
}
......@@ -239,7 +246,7 @@
}
}
@media(max-width: $screen-xs-max) {
@media (max-width: $screen-xs-max) {
.atwho-view-ul {
width: 350px;
}
......
......@@ -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;
}
......@@ -10,7 +10,6 @@
}
.axis {
fill: $stat-graph-axis-fill;
font-size: 10px;
}
......@@ -54,9 +53,7 @@
}
.selection rect {
fill: $stat-graph-selection-fill;
fill-opacity: 0.1;
stroke: $stat-graph-selection-stroke;
stroke-width: 1px;
stroke-opacity: 0.4;
shape-rendering: crispedges;
......
......@@ -8,12 +8,12 @@ class AutocompleteController < ApplicationController
def users
@users = AutocompleteUsersFinder.new(params: params, current_user: current_user, project: @project, group: @group).execute
render json: @users, only: [:name, :username, :id], methods: [:avatar_url]
render json: UserSerializer.new.represent(@users)
end
def user
@user = User.find(params[:id])
render json: @user, only: [:name, :username, :id], methods: [:avatar_url]
render json: UserSerializer.new.represent(@user)
end
def projects
......
class IdeController < ApplicationController
layout 'nav_only'
def index
end
end
......@@ -5,9 +5,6 @@ class Projects::BlobController < Projects::ApplicationController
include RendersBlob
include ActionView::Helpers::SanitizeHelper
# Raised when given an invalid file path
InvalidPathError = Class.new(StandardError)
prepend_before_action :authenticate_user!, only: [:edit]
before_action :require_non_empty_project, except: [:new, :create]
......@@ -61,7 +58,6 @@ class Projects::BlobController < Projects::ApplicationController
create_commit(Files::UpdateService, success_path: -> { after_edit_path },
failure_view: :edit,
failure_path: project_blob_path(@project, @id))
rescue Files::UpdateService::FileChangedError
@conflict = true
render :edit
......@@ -132,7 +128,6 @@ class Projects::BlobController < Projects::ApplicationController
def assign_blob_vars
@id = params[:id]
@ref, @path = extract_ref(@id)
rescue InvalidPathError
render_404
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
......
......@@ -35,7 +35,7 @@ module FormHelper
multi_select: true,
'input-meta': 'name',
'always-show-selectbox': true,
current_user_info: current_user.to_json(only: [:id, :name])
current_user_info: UserSerializer.new.represent(current_user)
}
}
end
......
......@@ -362,7 +362,7 @@ module IssuablesHelper
moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable),
projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id),
editable: can_edit_issuable,
currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url),
currentUser: UserSerializer.new.represent(current_user),
rootPath: root_path,
fullPath: @project.full_path
}
......
......@@ -139,7 +139,7 @@ module SearchHelper
id: "filtered-search-#{type}",
placeholder: 'Search or filter results...',
data: {
'username-params' => @users.to_json(only: [:id, :username])
'username-params' => UserSerializer.new.represent(@users)
},
autocomplete: 'off'
}
......
......@@ -43,14 +43,20 @@ module SortingHelper
end
def groups_sort_options_hash
options = {
{
sort_value_name => sort_title_name,
sort_value_name_desc => sort_title_name_desc,
sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created,
sort_value_recently_updated => sort_title_recently_updated,
sort_value_oldest_updated => sort_title_oldest_updated
}
end
options
def admin_groups_sort_options_hash
groups_sort_options_hash.merge(
sort_value_largest_group => sort_title_largest_group
)
end
def member_sort_options_hash
......
# Overrides `as_json` and `to_json` to raise an exception when called in order
# to prevent accidentally exposing attributes
#
# Not that that would ever happen... but just in case.
module BlocksJsonSerialization
extend ActiveSupport::Concern
JsonSerializationError = Class.new(StandardError)
def to_json(*)
raise JsonSerializationError,
"JSON serialization has been disabled on #{self.class.name}"
end
alias_method :as_json, :to_json
end
......@@ -24,7 +24,7 @@ module TimeTrackable
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def spend_time(options)
@time_spent = options[:duration]
@time_spent_user = options[:user]
@time_spent_user = User.find(options[:user_id])
@spent_at = options[:spent_at]
@original_total_time_spent = nil
......
......@@ -8,6 +8,8 @@ class Identity < ActiveRecord::Base
validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider, case_sensitive: false }
validates :user_id, uniqueness: { scope: :provider }
before_save :ensure_normalized_extern_uid, if: :extern_uid_changed?
scope :with_provider, ->(provider) { where(provider: provider) }
scope :with_extern_uid, ->(provider, extern_uid) do
iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider)
......@@ -24,4 +26,12 @@ class Identity < ActiveRecord::Base
uid.to_s
end
end
private
def ensure_normalized_extern_uid
return if extern_uid.nil?
self.extern_uid = Identity.normalize_uid(self.provider, self.extern_uid)
end
end
......@@ -1148,7 +1148,7 @@ class Project < ActiveRecord::Base
def change_head(branch)
if repository.branch_exists?(branch)
repository.before_change_head
repository.write_ref('HEAD', "refs/heads/#{branch}", force: true)
repository.write_ref('HEAD', "refs/heads/#{branch}")
repository.copy_gitattributes(branch)
repository.after_change_head
reload_default_branch
......
......@@ -19,7 +19,6 @@ class Repository
attr_accessor :full_path, :disk_path, :project, :is_wiki
delegate :ref_name_for_sha, to: :raw_repository
delegate :write_ref, to: :raw_repository
CreateTreeError = Class.new(StandardError)
......@@ -256,10 +255,11 @@ class Repository
# This will still fail if the file is corrupted (e.g. 0 bytes)
begin
write_ref(keep_around_ref_name(sha), sha, force: true)
rescue Gitlab::Git::Repository::GitError => ex
# Necessary because https://gitlab.com/gitlab-org/gitlab-ce/issues/20156
return true if ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/
write_ref(keep_around_ref_name(sha), sha)
rescue Rugged::ReferenceError => ex
Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}"
rescue Rugged::OSError => ex
raise unless ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/
Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}"
end
......@@ -269,6 +269,10 @@ class Repository
ref_exists?(keep_around_ref_name(sha))
end
def write_ref(ref_path, sha)
rugged.references.create(ref_path, sha, force: true)
end
def diverging_commit_counts(branch)
root_ref_hash = raw_repository.commit(root_ref).id
cache.fetch(:"diverging_commit_counts_#{branch.name}") do
......@@ -1015,7 +1019,7 @@ class Repository
end
def create_ref(ref, ref_path)
write_ref(ref_path, ref)
raw_repository.write_ref(ref_path, ref)
end
def ls_files(ref)
......
......@@ -18,6 +18,7 @@ class User < ActiveRecord::Base
include CreatedAtFilterable
include IgnorableColumn
include BulkMemberAccessLoad
include BlocksJsonSerialization
DEFAULT_NOTIFICATION_LEVEL = :participating
......
......@@ -7,7 +7,7 @@ class MergeRequestSerializer < BaseSerializer
case opts[:serializer]
when 'basic', 'sidebar'
MergeRequestBasicEntity
when 'widget'
else # It's 'widget'
MergeRequestWidgetEntity
end
......
module Files
class BaseService < Commits::CreateService
FileChangedError = Class.new(StandardError)
def initialize(*args)
super
@author_email = params[:author_email]
@author_name = params[:author_name]
@commit_message = params[:commit_message]
@last_commit_sha = params[:last_commit_sha]
@file_path = params[:file_path]
@previous_path = params[:previous_path]
......@@ -13,5 +16,16 @@ module Files
@file_content = params[:file_content]
@file_content = Base64.decode64(@file_content) if params[:file_content_encoding] == 'base64'
end
def file_has_changed?(path, commit_id)
return false unless commit_id
last_commit = Gitlab::Git::Commit
.last_for_path(@start_project.repository, @start_branch, path)
return false unless last_commit
last_commit.sha != commit_id
end
end
end
......@@ -11,5 +11,15 @@ module Files
start_project: @start_project,
start_branch_name: @start_branch)
end
private
def validate!
super
if file_has_changed?(@file_path, @last_commit_sha)
raise FileChangedError, "You are attempting to delete a file that has been previously updated."
end
end
end
end
module Files
class MultiService < Files::BaseService
UPDATE_FILE_ACTIONS = %w(update move delete).freeze
def create_commit!
repository.multi_action(
user: current_user,
......@@ -20,6 +22,7 @@ module Files
params[:actions].each do |action|
validate_action!(action)
validate_file_status!(action)
end
end
......@@ -28,5 +31,15 @@ module Files
raise_error("Unknown action '#{action[:action]}'")
end
end
def validate_file_status!(action)
return unless UPDATE_FILE_ACTIONS.include?(action[:action])
file_path = action[:previous_path] || action[:file_path]
if file_has_changed?(file_path, action[:last_commit_id])
raise_error("The file has changed since you started editing it: #{file_path}")
end
end
end
end
module Files
class UpdateService < Files::BaseService
FileChangedError = Class.new(StandardError)
def initialize(*args)
super
@last_commit_sha = params[:last_commit_sha]
end
def create_commit!
repository.update_file(current_user, @file_path, @file_content,
message: @commit_message,
......@@ -21,21 +13,10 @@ module Files
private
def file_has_changed?
return false unless @last_commit_sha && last_commit
@last_commit_sha != last_commit.sha
end
def last_commit
@last_commit ||= Gitlab::Git::Commit
.last_for_path(@start_project.repository, @start_branch, @file_path)
end
def validate!
super
if file_has_changed?
if file_has_changed?(@file_path, @last_commit_sha)
raise FileChangedError, "You are attempting to update a file that has changed since you started editing it."
end
end
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment